fix(metrics): align fleet series by time bucket, harden fleet page inputs

Address review findings on the fleet metrics page.

Data correctness:
- bucket raw Sentinel samples into epoch-aligned windows (5m/30m/2h per range)
  and average them, replacing per-server LTTB. Every server now reports the
  same bucket timestamps, so the fleet sums add up point by point instead of
  zigzagging between servers (and RX+TX really combine)
- keep only the root mount in disk history, matching HasMetrics::getDiskMetrics
- leave sparkline gaps as null instead of 0, so absent samples read as gaps

Robustness:
- normalize containerMetric in mount()/updatedContainerMetric(), so
  /metrics?cmetric=foo no longer throws an undefined array key
- include the server uuid in the container row wire:key; system containers
  share one Sentinel id across servers
- round the range start to the minute so the 60s history cache can hit
- cap the Sentinel curl with --max-time 10, so one hung request cannot stall
  the serial per-server loop

UI:
- pass the spark time axis on first paint, so tooltips no longer show 1970
- show the active series name (RX/TX) in the server network chart tooltip
This commit is contained in:
Aditya Tripathi
2026-09-18 07:44:22 +00:00
parent ab436942c9
commit faca466cd6
9 changed files with 163 additions and 54 deletions
@@ -55,8 +55,8 @@ trait BuildsMetricsChartPayload
* evenly-spaced indices (first and last always kept), so the series stay aligned.
*
* @param array<int, int> $categories
* @param array<string, array<int, float|int>> $sparks
* @return array{0: array<int, int>, 1: array<string, array<int, float|int>>}
* @param array<string, array<int, float|int|null>> $sparks
* @return array{0: array<int, int>, 1: array<string, array<int, float|int|null>>}
*/
private function capSparks(array $categories, array $sparks, int $max): array
{
@@ -117,11 +117,11 @@ trait BuildsMetricsChartPayload
}
/**
* Project a [ts, value] series onto the shared category axis, filling gaps with 0.
* Project a [ts, value] series onto the shared category axis, leaving gaps as null.
*
* @param array<int, array{0: int, 1: float}> $series
* @param array<int, int> $categories
* @return array<int, float|int>
* @return array<int, float|int|null>
*/
private function alignSpark(array $series, array $categories): array
{
@@ -130,6 +130,6 @@ trait BuildsMetricsChartPayload
$map[(int) $ts] = $value;
}
return array_map(fn ($ts) => $map[$ts] ?? 0, $categories);
return array_map(fn ($ts) => $map[$ts] ?? null, $categories);
}
}
+19 -7
View File
@@ -35,6 +35,8 @@ class Metrics extends Component
#[Url(as: 'cmetric')]
public string $containerMetric = 'cpu';
private const CONTAINER_METRICS = ['cpu', 'memory', 'disk', 'network'];
public bool $live = false;
/** @var array<string, mixed> */
@@ -71,6 +73,8 @@ class Metrics extends Component
$this->serverUuid = '';
}
$this->normalizeContainerMetric();
if ($this->servers->isNotEmpty()) {
$this->loadData();
}
@@ -83,10 +87,19 @@ class Metrics extends Component
public function updatedContainerMetric(): void
{
$this->normalizeContainerMetric();
// Re-rank the already-fetched containers; no need to re-hit Sentinel.
$this->topContainers = FleetMetricsAggregator::rankContainers($this->containersRaw, $this->containerMetric);
}
private function normalizeContainerMetric(): void
{
if (! in_array($this->containerMetric, self::CONTAINER_METRICS, true)) {
$this->containerMetric = 'cpu';
}
}
public function setRange(string $range): void
{
$this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h';
@@ -119,7 +132,6 @@ class Metrics extends Component
$rows = [];
$containers = [];
$series = ['cpu' => [], 'memory' => [], 'disk' => [], 'load' => [], 'networkRx' => [], 'networkTx' => []];
$from = SentinelMetricsClient::rangeFrom($this->range);
foreach ($this->targetServers() as $server) {
$client = $this->metricsClient($server);
@@ -145,14 +157,14 @@ class Metrics extends Component
foreach ($current as $c) {
// Names/images are resolved lazily for the displayed rows only (containerMeta),
// so a server with many containers doesn't trigger a resolve storm here.
$containers[] = array_merge($c, ['server' => $server->name]);
$containers[] = array_merge($c, ['server' => $server->name, 'serverUuid' => $server->uuid]);
}
$series['cpu'][] = $client->history('cpu', $from);
$series['memory'][] = $client->history('memory', $from);
$series['disk'][] = $client->history('disk', $from);
$series['load'][] = $client->history('load', $from);
$net = $client->networkHistory($from);
$series['cpu'][] = $client->history('cpu', $this->range);
$series['memory'][] = $client->history('memory', $this->range);
$series['disk'][] = $client->history('disk', $this->range);
$series['load'][] = $client->history('load', $this->range);
$net = $client->networkHistory($this->range);
$series['networkRx'][] = $net['rx'];
$series['networkTx'][] = $net['tx'];
}
+41 -24
View File
@@ -9,22 +9,32 @@ class SentinelMetricsClient
{
private string $base = 'http://localhost:8888/api';
/** Cap on points per trend series after downsampling raw Sentinel samples. */
private const MAX_SERIES_POINTS = 300;
public function __construct(protected Server $server) {}
/** Rounded to the minute so the history URL (and its cache key) is stable within a minute. */
public static function rangeFrom(string $range): string
{
$now = now()->startOfMinute();
$from = match ($range) {
'7d' => now()->subDays(7),
'30d' => now()->subDays(30),
default => now()->subDay(),
'7d' => $now->subDays(7),
'30d' => $now->subDays(30),
default => $now->subDay(),
};
return $from->toIso8601ZuluString();
}
/** Bucket width per range, kept at roughly 300 points per series. */
public static function bucketMs(string $range): int
{
return match ($range) {
'7d' => 30 * 60_000,
'30d' => 2 * 60 * 60_000,
default => 5 * 60_000,
};
}
/**
* @return array<string, mixed>|null
*/
@@ -89,7 +99,7 @@ class SentinelMetricsClient
/**
* @return array<int, array{0: int, 1: float}>
*/
public function history(string $metric, string $from): array
public function history(string $metric, string $range): array
{
[$path, $field] = match ($metric) {
'memory' => ['/memory/history', 'used'],
@@ -98,26 +108,26 @@ class SentinelMetricsClient
default => ['/cpu/history', 'percent'],
};
return $this->seriesRows($this->url($path, ['from' => $from]), $field);
return $this->seriesRows($this->url($path, ['from' => self::rangeFrom($range)]), $field, $range);
}
/**
* @return array{rx: array<int, array{0: int, 1: float}>, tx: array<int, array{0: int, 1: float}>}
*/
public function networkHistory(string $from): array
public function networkHistory(string $range): array
{
$url = $this->url('/network/history', ['from' => $from]);
$url = $this->url('/network/history', ['from' => self::rangeFrom($range)]);
return [
'rx' => $this->seriesRows($url, 'rxBytesPerSec'),
'tx' => $this->seriesRows($url, 'txBytesPerSec'),
'rx' => $this->seriesRows($url, 'rxBytesPerSec', $range),
'tx' => $this->seriesRows($url, 'txBytesPerSec', $range),
];
}
/**
* @return array<int, array{0: int, 1: float}>
*/
private function seriesRows(string $url, string $field): array
private function seriesRows(string $url, string $field, string $range): array
{
try {
$rows = json_decode($this->raw($url), true);
@@ -129,18 +139,25 @@ class SentinelMetricsClient
return [];
}
$series = array_values(array_map(
fn ($r) => [(int) ($r['time'] ?? 0), (float) ($r[$field] ?? 0)],
$rows
));
// Sentinel returns raw samples (thousands of points over 24h). Cap the series so
// the chart payload stays small enough to embed and morph cheaply.
if (count($series) > self::MAX_SERIES_POINTS) {
$series = downsampleLTTB($series, self::MAX_SERIES_POINTS);
// Disk history interleaves every mount; keep the root filesystem only.
$root = array_filter($rows, fn ($r) => ($r['mount'] ?? null) === '/');
if ($root !== []) {
$rows = $root;
}
return $series;
// Average raw samples into epoch-aligned buckets so every server shares the same
// timestamps and the fleet aggregator can sum them point by point.
$bucketMs = self::bucketMs($range);
$sums = [];
$counts = [];
foreach ($rows as $r) {
$bucket = intdiv((int) ($r['time'] ?? 0), $bucketMs) * $bucketMs;
$sums[$bucket] = ($sums[$bucket] ?? 0.0) + (float) ($r[$field] ?? 0);
$counts[$bucket] = ($counts[$bucket] ?? 0) + 1;
}
ksort($sums);
return array_map(fn ($bucket) => [$bucket, round($sums[$bucket] / $counts[$bucket], 2)], array_keys($sums));
}
/**
@@ -187,7 +204,7 @@ class SentinelMetricsClient
$token = $this->server->settings->ensureValidSentinelToken();
return instant_remote_process(
["docker exec coolify-sentinel sh -c 'curl -s -H \"Authorization: Bearer {$token}\" \"{$url}\"'"],
["docker exec coolify-sentinel sh -c 'curl -s --max-time 10 -H \"Authorization: Bearer {$token}\" \"{$url}\"'"],
$this->server,
false
);