From f199b6bfc4be1afc11b8a2a3bdc8878dc85e4daf Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 27 Dec 2025 19:10:02 +0100 Subject: [PATCH 1/2] fix(metrics): prevent page freeze with 30-day server metrics interval using LTTB downsampling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the Largest-Triangle-Three-Buckets (LTTB) algorithm to downsample metrics data for large time intervals (30 days generates 260K-500K+ points). Reduces rendered points to ~1000 while preserving visual accuracy of peaks and valleys. Fixes unresponsive page when selecting 30-day metrics interval. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 --- app/Models/Server.php | 98 +++++++++- tests/Unit/ServerMetricsDownsamplingTest.php | 183 +++++++++++++++++++ 2 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/ServerMetricsDownsamplingTest.php diff --git a/app/Models/Server.php b/app/Models/Server.php index be39e3f8d..c4efe832b 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -682,9 +682,16 @@ public function getCpuMetrics(int $mins = 5) } $cpu = json_decode($cpu, true); - return collect($cpu)->map(function ($metric) { + $metrics = collect($cpu)->map(function ($metric) { return [(int) $metric['time'], (float) $metric['percent']]; - }); + })->toArray(); + + // Downsample for intervals > 60 minutes to prevent browser freeze + if ($mins > 60 && count($metrics) > 1000) { + $metrics = $this->downsampleLTTB($metrics, 1000); + } + + return $metrics; } } @@ -702,16 +709,97 @@ public function getMemoryMetrics(int $mins = 5) throw new \Exception($error); } $memory = json_decode($memory, true); - $parsedCollection = collect($memory)->map(function ($metric) { + $metrics = collect($memory)->map(function ($metric) { $usedPercent = $metric['usedPercent'] ?? 0.0; return [(int) $metric['time'], (float) $usedPercent]; - }); + })->toArray(); - return $parsedCollection->toArray(); + // Downsample for intervals > 60 minutes to prevent browser freeze + if ($mins > 60 && count($metrics) > 1000) { + $metrics = $this->downsampleLTTB($metrics, 1000); + } + + return $metrics; } } + /** + * Downsample metrics using the Largest-Triangle-Three-Buckets (LTTB) algorithm. + * This preserves the visual shape of the data better than simple averaging. + * + * @param array $data Array of [timestamp, value] pairs + * @param int $threshold Target number of points + * @return array Downsampled data + */ + private function downsampleLTTB(array $data, int $threshold): array + { + $dataLength = count($data); + + if ($threshold >= $dataLength || $threshold <= 2) { + return $data; + } + + $sampled = []; + $sampled[] = $data[0]; // Always keep first point + + $bucketSize = ($dataLength - 2) / ($threshold - 2); + + $a = 0; // Index of previous selected point + + for ($i = 0; $i < $threshold - 2; $i++) { + // Calculate bucket range + $bucketStart = (int) floor(($i + 1) * $bucketSize) + 1; + $bucketEnd = (int) floor(($i + 2) * $bucketSize) + 1; + $bucketEnd = min($bucketEnd, $dataLength - 1); + + // Calculate average point for next bucket (used as reference) + $nextBucketStart = (int) floor(($i + 2) * $bucketSize) + 1; + $nextBucketEnd = (int) floor(($i + 3) * $bucketSize) + 1; + $nextBucketEnd = min($nextBucketEnd, $dataLength - 1); + + $avgX = 0; + $avgY = 0; + $nextBucketCount = $nextBucketEnd - $nextBucketStart + 1; + + if ($nextBucketCount > 0) { + for ($j = $nextBucketStart; $j <= $nextBucketEnd; $j++) { + $avgX += $data[$j][0]; + $avgY += $data[$j][1]; + } + $avgX /= $nextBucketCount; + $avgY /= $nextBucketCount; + } + + // Find point in current bucket with largest triangle area + $maxArea = -1; + $maxAreaIndex = $bucketStart; + + $pointAX = $data[$a][0]; + $pointAY = $data[$a][1]; + + for ($j = $bucketStart; $j <= $bucketEnd; $j++) { + // Triangle area calculation + $area = abs( + ($pointAX - $avgX) * ($data[$j][1] - $pointAY) - + ($pointAX - $data[$j][0]) * ($avgY - $pointAY) + ) * 0.5; + + if ($area > $maxArea) { + $maxArea = $area; + $maxAreaIndex = $j; + } + } + + $sampled[] = $data[$maxAreaIndex]; + $a = $maxAreaIndex; + } + + $sampled[] = $data[$dataLength - 1]; // Always keep last point + + return $sampled; + } + public function getDiskUsage(): ?string { return instant_remote_process(['df / --output=pcent | tr -cd 0-9'], $this, false); diff --git a/tests/Unit/ServerMetricsDownsamplingTest.php b/tests/Unit/ServerMetricsDownsamplingTest.php new file mode 100644 index 000000000..cb512289f --- /dev/null +++ b/tests/Unit/ServerMetricsDownsamplingTest.php @@ -0,0 +1,183 @@ += $dataLength || $threshold <= 2) { + return $data; + } + + $sampled = []; + $sampled[] = $data[0]; // Always keep first point + + $bucketSize = ($dataLength - 2) / ($threshold - 2); + + $a = 0; // Index of previous selected point + + for ($i = 0; $i < $threshold - 2; $i++) { + // Calculate bucket range + $bucketStart = (int) floor(($i + 1) * $bucketSize) + 1; + $bucketEnd = (int) floor(($i + 2) * $bucketSize) + 1; + $bucketEnd = min($bucketEnd, $dataLength - 1); + + // Calculate average point for next bucket (used as reference) + $nextBucketStart = (int) floor(($i + 2) * $bucketSize) + 1; + $nextBucketEnd = (int) floor(($i + 3) * $bucketSize) + 1; + $nextBucketEnd = min($nextBucketEnd, $dataLength - 1); + + $avgX = 0; + $avgY = 0; + $nextBucketCount = $nextBucketEnd - $nextBucketStart + 1; + + if ($nextBucketCount > 0) { + for ($j = $nextBucketStart; $j <= $nextBucketEnd; $j++) { + $avgX += $data[$j][0]; + $avgY += $data[$j][1]; + } + $avgX /= $nextBucketCount; + $avgY /= $nextBucketCount; + } + + // Find point in current bucket with largest triangle area + $maxArea = -1; + $maxAreaIndex = $bucketStart; + + $pointAX = $data[$a][0]; + $pointAY = $data[$a][1]; + + for ($j = $bucketStart; $j <= $bucketEnd; $j++) { + // Triangle area calculation + $area = abs( + ($pointAX - $avgX) * ($data[$j][1] - $pointAY) - + ($pointAX - $data[$j][0]) * ($avgY - $pointAY) + ) * 0.5; + + if ($area > $maxArea) { + $maxArea = $area; + $maxAreaIndex = $j; + } + } + + $sampled[] = $data[$maxAreaIndex]; + $a = $maxAreaIndex; + } + + $sampled[] = $data[$dataLength - 1]; // Always keep last point + + return $sampled; +} + +it('returns data unchanged when below threshold', function () { + $data = [ + [1000, 10.5], + [2000, 20.3], + [3000, 15.7], + ]; + + $result = downsampleLTTB($data, 1000); + + expect($result)->toBe($data); +}); + +it('returns data unchanged when threshold is 2 or less', function () { + $data = [ + [1000, 10.5], + [2000, 20.3], + [3000, 15.7], + [4000, 25.0], + [5000, 12.0], + ]; + + $result = downsampleLTTB($data, 2); + expect($result)->toBe($data); + + $result = downsampleLTTB($data, 1); + expect($result)->toBe($data); +}); + +it('downsamples to target threshold count', function () { + // Generate 100 data points + $data = []; + for ($i = 0; $i < 100; $i++) { + $data[] = [$i * 1000, rand(0, 100) / 10]; + } + + $result = downsampleLTTB($data, 10); + + expect(count($result))->toBe(10); +}); + +it('preserves first and last data points', function () { + $data = []; + for ($i = 0; $i < 100; $i++) { + $data[] = [$i * 1000, $i * 1.5]; + } + + $result = downsampleLTTB($data, 20); + + // First point should be preserved + expect($result[0])->toBe($data[0]); + + // Last point should be preserved + expect(end($result))->toBe(end($data)); +}); + +it('maintains chronological order', function () { + $data = []; + for ($i = 0; $i < 500; $i++) { + $data[] = [$i * 60000, sin($i / 10) * 50 + 50]; // Sine wave pattern + } + + $result = downsampleLTTB($data, 50); + + // Verify all timestamps are in non-decreasing order + $previousTimestamp = -1; + foreach ($result as $point) { + expect($point[0])->toBeGreaterThanOrEqual($previousTimestamp); + $previousTimestamp = $point[0]; + } +}); + +it('handles large datasets efficiently', function () { + // Simulate 30 days of data at 5-second intervals (518,400 points) + // For test purposes, use 10,000 points + $data = []; + for ($i = 0; $i < 10000; $i++) { + $data[] = [$i * 5000, rand(0, 100)]; + } + + $startTime = microtime(true); + $result = downsampleLTTB($data, 1000); + $executionTime = microtime(true) - $startTime; + + expect(count($result))->toBe(1000); + expect($executionTime)->toBeLessThan(1.0); // Should complete in under 1 second +}); + +it('preserves peaks and valleys in data', function () { + // Create data with clear peaks and valleys + $data = []; + for ($i = 0; $i < 100; $i++) { + if ($i === 25) { + $value = 100; // Peak + } elseif ($i === 75) { + $value = 0; // Valley + } else { + $value = 50; + } + $data[] = [$i * 1000, $value]; + } + + $result = downsampleLTTB($data, 20); + + // The peak (100) and valley (0) should be preserved due to LTTB algorithm + $values = array_column($result, 1); + + expect(in_array(100, $values))->toBeTrue(); + expect(in_array(0, $values))->toBeTrue(); +}); From 0e9dbc362574d24316642adfc0364512902b2674 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 28 Dec 2025 13:39:43 +0100 Subject: [PATCH 2/2] fix(metrics): address code review feedback for LTTB downsampling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap return values in collect() to maintain Collection compatibility - Add comment explaining threshold <= 2 prevents division by zero - Refactor tests to use actual Server model method via reflection - Use seeded mt_rand() for reproducible test results 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/Models/Server.php | 6 +- tests/Unit/ServerMetricsDownsamplingTest.php | 100 +++++-------------- 2 files changed, 28 insertions(+), 78 deletions(-) diff --git a/app/Models/Server.php b/app/Models/Server.php index c4efe832b..fd1ce3e69 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -691,7 +691,7 @@ public function getCpuMetrics(int $mins = 5) $metrics = $this->downsampleLTTB($metrics, 1000); } - return $metrics; + return collect($metrics); } } @@ -720,7 +720,7 @@ public function getMemoryMetrics(int $mins = 5) $metrics = $this->downsampleLTTB($metrics, 1000); } - return $metrics; + return collect($metrics); } } @@ -736,6 +736,8 @@ private function downsampleLTTB(array $data, int $threshold): array { $dataLength = count($data); + // Return unchanged if threshold >= data length, or if threshold <= 2 + // (threshold <= 2 would cause division by zero in bucket calculation) if ($threshold >= $dataLength || $threshold <= 2) { return $data; } diff --git a/tests/Unit/ServerMetricsDownsamplingTest.php b/tests/Unit/ServerMetricsDownsamplingTest.php index cb512289f..819eecc4c 100644 --- a/tests/Unit/ServerMetricsDownsamplingTest.php +++ b/tests/Unit/ServerMetricsDownsamplingTest.php @@ -1,75 +1,17 @@ getMethod('downsampleLTTB'); - if ($threshold >= $dataLength || $threshold <= 2) { - return $data; - } - - $sampled = []; - $sampled[] = $data[0]; // Always keep first point - - $bucketSize = ($dataLength - 2) / ($threshold - 2); - - $a = 0; // Index of previous selected point - - for ($i = 0; $i < $threshold - 2; $i++) { - // Calculate bucket range - $bucketStart = (int) floor(($i + 1) * $bucketSize) + 1; - $bucketEnd = (int) floor(($i + 2) * $bucketSize) + 1; - $bucketEnd = min($bucketEnd, $dataLength - 1); - - // Calculate average point for next bucket (used as reference) - $nextBucketStart = (int) floor(($i + 2) * $bucketSize) + 1; - $nextBucketEnd = (int) floor(($i + 3) * $bucketSize) + 1; - $nextBucketEnd = min($nextBucketEnd, $dataLength - 1); - - $avgX = 0; - $avgY = 0; - $nextBucketCount = $nextBucketEnd - $nextBucketStart + 1; - - if ($nextBucketCount > 0) { - for ($j = $nextBucketStart; $j <= $nextBucketEnd; $j++) { - $avgX += $data[$j][0]; - $avgY += $data[$j][1]; - } - $avgX /= $nextBucketCount; - $avgY /= $nextBucketCount; - } - - // Find point in current bucket with largest triangle area - $maxArea = -1; - $maxAreaIndex = $bucketStart; - - $pointAX = $data[$a][0]; - $pointAY = $data[$a][1]; - - for ($j = $bucketStart; $j <= $bucketEnd; $j++) { - // Triangle area calculation - $area = abs( - ($pointAX - $avgX) * ($data[$j][1] - $pointAY) - - ($pointAX - $data[$j][0]) * ($avgY - $pointAY) - ) * 0.5; - - if ($area > $maxArea) { - $maxArea = $area; - $maxAreaIndex = $j; - } - } - - $sampled[] = $data[$maxAreaIndex]; - $a = $maxAreaIndex; - } - - $sampled[] = $data[$dataLength - 1]; // Always keep last point - - return $sampled; + return $method->invoke($server, $data, $threshold); } it('returns data unchanged when below threshold', function () { @@ -79,7 +21,7 @@ function downsampleLTTB(array $data, int $threshold): array [3000, 15.7], ]; - $result = downsampleLTTB($data, 1000); + $result = callDownsampleLTTB($data, 1000); expect($result)->toBe($data); }); @@ -93,21 +35,24 @@ function downsampleLTTB(array $data, int $threshold): array [5000, 12.0], ]; - $result = downsampleLTTB($data, 2); + $result = callDownsampleLTTB($data, 2); expect($result)->toBe($data); - $result = downsampleLTTB($data, 1); + $result = callDownsampleLTTB($data, 1); expect($result)->toBe($data); }); it('downsamples to target threshold count', function () { + // Seed for reproducibility + mt_srand(42); + // Generate 100 data points $data = []; for ($i = 0; $i < 100; $i++) { - $data[] = [$i * 1000, rand(0, 100) / 10]; + $data[] = [$i * 1000, mt_rand(0, 100) / 10]; } - $result = downsampleLTTB($data, 10); + $result = callDownsampleLTTB($data, 10); expect(count($result))->toBe(10); }); @@ -118,7 +63,7 @@ function downsampleLTTB(array $data, int $threshold): array $data[] = [$i * 1000, $i * 1.5]; } - $result = downsampleLTTB($data, 20); + $result = callDownsampleLTTB($data, 20); // First point should be preserved expect($result[0])->toBe($data[0]); @@ -133,7 +78,7 @@ function downsampleLTTB(array $data, int $threshold): array $data[] = [$i * 60000, sin($i / 10) * 50 + 50]; // Sine wave pattern } - $result = downsampleLTTB($data, 50); + $result = callDownsampleLTTB($data, 50); // Verify all timestamps are in non-decreasing order $previousTimestamp = -1; @@ -144,15 +89,18 @@ function downsampleLTTB(array $data, int $threshold): array }); it('handles large datasets efficiently', function () { + // Seed for reproducibility + mt_srand(123); + // Simulate 30 days of data at 5-second intervals (518,400 points) // For test purposes, use 10,000 points $data = []; for ($i = 0; $i < 10000; $i++) { - $data[] = [$i * 5000, rand(0, 100)]; + $data[] = [$i * 5000, mt_rand(0, 100)]; } $startTime = microtime(true); - $result = downsampleLTTB($data, 1000); + $result = callDownsampleLTTB($data, 1000); $executionTime = microtime(true) - $startTime; expect(count($result))->toBe(1000); @@ -173,7 +121,7 @@ function downsampleLTTB(array $data, int $threshold): array $data[] = [$i * 1000, $value]; } - $result = downsampleLTTB($data, 20); + $result = callDownsampleLTTB($data, 20); // The peak (100) and valley (0) should be preserved due to LTTB algorithm $values = array_column($result, 1);