diff --git a/app/Services/FleetMetricsAggregator.php b/app/Services/FleetMetricsAggregator.php new file mode 100644 index 0000000000..72f6ddb10b --- /dev/null +++ b/app/Services/FleetMetricsAggregator.php @@ -0,0 +1,92 @@ +> $rows + * @return array + */ + public static function fleetKpis(array $rows): array + { + $online = array_values(array_filter($rows, fn ($r) => $r['online'] === true)); + + $cpuValues = self::present($online, 'cpu'); + $cpuAvg = $cpuValues === [] ? 0.0 : round(array_sum($cpuValues) / count($cpuValues), 1); + + $busiest = null; + foreach ($online as $r) { + if ($r['cpu'] !== null && ($busiest === null || $r['cpu'] > $busiest['percent'])) { + $busiest = ['name' => $r['name'], 'percent' => (float) $r['cpu']]; + } + } + + $memUsed = (int) self::sum($online, 'memUsed'); + $memTotal = (int) self::sum($online, 'memTotal'); + $diskUsed = (int) self::sum($online, 'diskUsed'); + $diskTotal = (int) self::sum($online, 'diskTotal'); + $load1 = self::present($online, 'load1'); + + $needsAttention = 0; + foreach ($online as $r) { + $hot = PressureLevel::for($r['cpu'], 'cpu') !== 'ok' + || PressureLevel::for(self::percent($r['memUsed'], $r['memTotal']), 'memory') !== 'ok' + || PressureLevel::for($r['diskPercent'], 'disk') !== 'ok'; + if ($hot) { + $needsAttention++; + } + } + + return [ + 'serversOnline' => count($online), + 'serversTotal' => count($rows), + 'needsAttention' => $needsAttention, + 'cpuAvg' => $cpuAvg, + 'cpuApproximate' => count($cpuValues) > 1, + 'cpuBusiest' => $busiest, + 'memUsed' => $memUsed, + 'memTotal' => $memTotal, + 'memPercent' => self::percent($memUsed, $memTotal), + 'diskUsed' => $diskUsed, + 'diskTotal' => $diskTotal, + 'diskPercent' => self::percent($diskUsed, $diskTotal), + 'netRx' => (float) self::sum($online, 'netRx'), + 'netTx' => (float) self::sum($online, 'netTx'), + 'loadBusiest' => $load1 === [] ? 0.0 : max($load1), + 'loadAvg' => $load1 === [] ? 0.0 : round(array_sum($load1) / count($load1), 2), + 'containers' => (int) self::sum($online, 'containers'), + ]; + } + + /** + * @param array> $rows + * @return array + */ + private static function present(array $rows, string $key): array + { + return array_values(array_map( + fn ($r) => (float) $r[$key], + array_filter($rows, fn ($r) => $r[$key] !== null) + )); + } + + /** + * @param array> $rows + */ + private static function sum(array $rows, string $key): float + { + return array_sum(array_map(fn ($r) => $r[$key] ?? 0, $rows)); + } + + private static function percent(int|float|null $used, int|float|null $total): float + { + if (! $total) { + return 0.0; + } + + return round(($used / $total) * 100, 1); + } +} diff --git a/tests/Unit/Services/FleetMetricsAggregatorTest.php b/tests/Unit/Services/FleetMetricsAggregatorTest.php new file mode 100644 index 0000000000..2de55616f7 --- /dev/null +++ b/tests/Unit/Services/FleetMetricsAggregatorTest.php @@ -0,0 +1,69 @@ + 'u', 'name' => 'srv', 'online' => true, + 'cpu' => 10.0, 'memUsed' => 1_000, 'memTotal' => 10_000, + 'diskUsed' => 1_000, 'diskTotal' => 10_000, 'diskPercent' => 10.0, + 'load1' => 0.5, 'netRx' => 100.0, 'netTx' => 50.0, 'containers' => 2, + ], $overrides); +} + +it('weights memory and disk by totals, not mean of percents', function () { + $rows = [ + row(['memUsed' => 100, 'memTotal' => 100, 'diskUsed' => 100, 'diskTotal' => 100, 'diskPercent' => 100.0]), + row(['memUsed' => 10, 'memTotal' => 1_000, 'diskUsed' => 10, 'diskTotal' => 1_000, 'diskPercent' => 1.0]), + ]; + + $kpis = FleetMetricsAggregator::fleetKpis($rows); + + // Σused/Σtotal = 110/1100 = 10%, not the (100+1)/2 = 50.5% a naive mean gives. + expect(round($kpis['memPercent'], 1))->toBe(10.0); + expect(round($kpis['diskPercent'], 1))->toBe(10.0); +}); + +it('means cpu, flags approximate for multiple servers, and finds the busiest', function () { + $kpis = FleetMetricsAggregator::fleetKpis([ + row(['name' => 'a', 'cpu' => 20.0]), + row(['name' => 'b', 'cpu' => 80.0]), + ]); + + expect($kpis['cpuAvg'])->toBe(50.0); + expect($kpis['cpuApproximate'])->toBeTrue(); + expect($kpis['cpuBusiest'])->toBe(['name' => 'b', 'percent' => 80.0]); +}); + +it('sums network rates and reports busiest and average load', function () { + $kpis = FleetMetricsAggregator::fleetKpis([ + row(['netRx' => 100.0, 'netTx' => 10.0, 'load1' => 0.5]), + row(['netRx' => 200.0, 'netTx' => 20.0, 'load1' => 2.5]), + ]); + + expect($kpis['netRx'])->toBe(300.0); + expect($kpis['netTx'])->toBe(30.0); + expect($kpis['loadBusiest'])->toBe(2.5); + expect($kpis['loadAvg'])->toBe(1.5); +}); + +it('ignores offline rows and counts servers needing attention', function () { + $kpis = FleetMetricsAggregator::fleetKpis([ + row(['cpu' => 95.0]), // over cpu error cutoff + row(['online' => false, 'cpu' => 99.0]), + ]); + + expect($kpis['serversOnline'])->toBe(1); + expect($kpis['serversTotal'])->toBe(2); + expect($kpis['needsAttention'])->toBe(1); + expect($kpis['cpuAvg'])->toBe(95.0); // offline row excluded from the mean +}); + +it('returns zeroed kpis for no rows', function () { + $kpis = FleetMetricsAggregator::fleetKpis([]); + + expect($kpis['serversOnline'])->toBe(0); + expect($kpis['cpuAvg'])->toBe(0.0); + expect($kpis['cpuBusiest'])->toBeNull(); +});