diff --git a/app/Traits/HasMetrics.php b/app/Traits/HasMetrics.php index cc096fd1e3..d39675276d 100644 --- a/app/Traits/HasMetrics.php +++ b/app/Traits/HasMetrics.php @@ -172,6 +172,11 @@ trait HasMetrics /** * Map raw rows to [timestampMs, value] pairs, downsampling long ranges. * + * Sentinel keeps raw samples for the last 24 to 48 hours and one-minute averages + * before that. LTTB buckets by point count, so on ranges that reach past the raw + * window the raw part got most of the chart points. Those ranges are averaged per + * minute first, the same as Sentinel does, so the density is even over time. + * * @param array>|null $rows */ private function mapRows(?array $rows, string $valueField, int $mins): ?array @@ -184,6 +189,10 @@ trait HasMetrics return [(int) $metric['time'], (float) ($metric[$valueField] ?? 0.0)]; })->toArray(); + if ($mins > 24 * 60) { + $metrics = $this->averagePerMinute($metrics); + } + if ($mins > 60 && count($metrics) > 1000) { $metrics = downsampleLTTB($metrics, 1000); } @@ -191,6 +200,28 @@ trait HasMetrics return $metrics; } + /** + * Average [timestampMs, value] pairs into one-minute buckets, timestamped at the + * bucket start like Sentinel's own downsampling. Samples that are a minute or more + * apart keep their own value. + * + * @param array $metrics + * @return array + */ + private function averagePerMinute(array $metrics): array + { + $sums = []; + $counts = []; + foreach ($metrics as [$time, $value]) { + $bucket = intdiv($time, 60_000) * 60_000; + $sums[$bucket] = ($sums[$bucket] ?? 0.0) + $value; + $counts[$bucket] = ($counts[$bucket] ?? 0) + 1; + } + ksort($sums); + + return array_map(fn (int $bucket) => [$bucket, $sums[$bucket] / $counts[$bucket]], array_keys($sums)); + } + private function metricsToken(Server $server): string { $previousToken = null; diff --git a/tests/Feature/ServerMetricsPrefetchTest.php b/tests/Feature/ServerMetricsPrefetchTest.php index ca1d6eeab8..85a51e1ac4 100644 --- a/tests/Feature/ServerMetricsPrefetchTest.php +++ b/tests/Feature/ServerMetricsPrefetchTest.php @@ -85,3 +85,25 @@ it('runs the batched metrics script with sudo for a non-root user', function () ->and($commands[0])->not->toContain('sudo curl') ->and($commands[0])->not->toContain('sudo printf'); }); + +it('keeps long-range charts evenly dense across mixed-resolution Sentinel history', function () { + // Sentinel keeps raw samples (here every 10 s) for the last one to two days and + // one-minute averages before that. A 7-day chart must not crowd the raw days. + $end = 1_800_000_000_000; + $rawStart = $end - 2 * 86_400_000; + $rows = []; + for ($time = $end - 7 * 86_400_000; $time < $rawStart; $time += 60_000) { + $rows[] = ['time' => $time, 'percent' => 10]; + } + for ($time = $rawStart; $time < $end; $time += 10_000) { + $rows[] = ['time' => $time, 'percent' => ($time / 10_000) % 2 === 0 ? 0 : 20]; + } + Process::fake(fn () => Process::result(json_encode($rows))); + + $metrics = $this->server->getCpuMetrics(10080); + $recent = array_filter($metrics, fn (array $point) => $point[0] >= $rawStart); + + expect(count($metrics))->toBeLessThanOrEqual(1000) + ->and(count($recent) / count($metrics))->toBeLessThan(0.35) + ->and(array_values(array_unique(array_column($recent, 1))))->toBe([10.0]); +});