feat(metrics): add disk/network/load charts to the per-server metrics page

This commit is contained in:
Aditya Tripathi
2026-09-16 08:43:37 +00:00
parent 9fced09bd1
commit f3fdee90da
4 changed files with 206 additions and 4 deletions
+22
View File
@@ -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) {
+69 -1
View File
@@ -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<int, array<string, mixed>>|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<int, array<string, mixed>>|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();
@@ -69,6 +69,21 @@
<div wire:ignore id="{!! $chartId !!}-memory" class="min-h-[240px] w-full"></div>
</x-application.settings-section>
<x-application.settings-section id="server-disk-metrics-section" title="Disk usage"
helper="Percentage of the root filesystem used by this server.">
<div wire:ignore id="{!! $chartId !!}-disk" class="min-h-[240px] w-full"></div>
</x-application.settings-section>
<x-application.settings-section id="server-network-metrics-section" title="Network throughput"
helper="Receive and transmit rate across the server's network interfaces.">
<div wire:ignore id="{!! $chartId !!}-network" class="min-h-[240px] w-full"></div>
</x-application.settings-section>
<x-application.settings-section id="server-load-metrics-section" title="Load average"
helper="One minute load average reported by the server.">
<div wire:ignore id="{!! $chartId !!}-load" class="min-h-[240px] w-full"></div>
</x-application.settings-section>
@script
<script>
(() => {
@@ -81,6 +96,16 @@
return `${Number(number.toFixed(precision))}%`;
};
const formatNumber = value => Number(Number(value).toFixed(2)).toLocaleString();
const formatBytes = value => {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let amount = Number(value) || 0;
let unit = 0;
while (amount >= 1024 && unit < units.length - 1) { amount /= 1024; unit++; }
return `${Number(amount.toFixed(unit === 0 ? 0 : 2))} ${units[unit]}`;
};
const formatRate = value => `${formatBytes(value)}/s`;
const formatLocalTimestamp = timestamp => new Date(timestamp).toLocaleString(undefined, {
hour12: false,
timeZoneName: 'short',
@@ -91,7 +116,7 @@
timeZoneName: 'short',
});
const chartOptions = (name, color, loadingText) => ({
const chartOptions = (name, color, loadingText, formatter = formatPercent) => ({
chart: {
height: 240,
type: 'area',
@@ -151,7 +176,7 @@
style: {
colors: textColor,
},
formatter: formatPercent,
formatter,
},
},
noData: {
@@ -180,7 +205,7 @@
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
return `<div class="apexcharts-tooltip-custom">
<div class="apexcharts-tooltip-custom-value">${name}: <span class="apexcharts-tooltip-value-bold">${formatPercent(value)}</span></div>
<div class="apexcharts-tooltip-custom-value">${name}: <span class="apexcharts-tooltip-value-bold">${formatter(value)}</span></div>
<div class="apexcharts-tooltip-custom-title">Your time: ${formatLocalTimestamp(timestamp)}</div>
<div class="apexcharts-tooltip-custom-title">UTC: ${formatUtcTimestamp(timestamp)}</div>
</div>`;
@@ -197,8 +222,28 @@
chartOptions('Memory', ramColor, 'Loading memory metrics…'),
);
const diskChart = new ApexCharts(
document.getElementById('{!! $chartId !!}-disk'),
chartOptions('Disk', '#ef4444', 'Loading disk metrics…', formatPercent),
);
const networkChart = new ApexCharts(
document.getElementById('{!! $chartId !!}-network'),
{
...chartOptions('Network', '#10b981', 'Loading network metrics…', formatRate),
colors: ['#10b981', '#f59e0b'],
legend: { show: true, labels: { colors: textColor } },
},
);
const loadChart = new ApexCharts(
document.getElementById('{!! $chartId !!}-load'),
chartOptions('Load', '#14b8a6', 'Loading load metrics…', formatNumber),
);
cpuChart.render();
memoryChart.render();
diskChart.render();
networkChart.render();
loadChart.render();
Livewire.on('refreshChartData-{!! $chartId !!}-metrics', chartData => {
checkTheme();
@@ -272,6 +317,12 @@
},
},
});
diskChart.updateSeries([{ name: 'Disk', data: data.diskSeries || [] }]);
networkChart.updateSeries([
{ name: 'RX', data: data.networkRxSeries || [] },
{ name: 'TX', data: data.networkTxSeries || [] },
]);
loadChart.updateSeries([{ name: 'Load', data: data.loadSeries || [] }]);
});
})();
</script>
@@ -0,0 +1,61 @@
<?php
use App\Traits\HasMetrics;
/**
* Test double exposing the new metric mappers without SSH: overrides the fetch step
* to return canned Sentinel rows.
*/
class MetricsMappingDouble
{
use HasMetrics;
/** @var array<string, ?array> 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();
});