mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-12 15:53:46 -04:00
feat(metrics): add disk/network/load charts to the per-server metrics page
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user