From dbcef807aebe92fca511172033a0ccdf198d06cb Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 07:54:34 +0000 Subject: [PATCH 01/19] feat(metrics): add reachable /metrics fleet page skeleton --- app/Livewire/Fleet/Metrics.php | 57 +++++++++++++++++++ resources/views/components/navbar.blade.php | 8 +++ .../fleet/metrics-placeholder.blade.php | 3 + .../views/livewire/fleet/metrics.blade.php | 14 +++++ routes/web.php | 2 + .../Livewire/Fleet/MetricsPageTest.php | 33 +++++++++++ 6 files changed, 117 insertions(+) create mode 100644 app/Livewire/Fleet/Metrics.php create mode 100644 resources/views/livewire/fleet/metrics-placeholder.blade.php create mode 100644 resources/views/livewire/fleet/metrics.blade.php create mode 100644 tests/Feature/Livewire/Fleet/MetricsPageTest.php diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php new file mode 100644 index 0000000000..8cbefffd00 --- /dev/null +++ b/app/Livewire/Fleet/Metrics.php @@ -0,0 +1,57 @@ + uuid => name */ + public array $serverOptions = []; + + #[Url(as: 'range')] + public string $range = '24h'; + + #[Url(as: 'server')] + public string $serverUuid = ''; + + public bool $live = false; + + public function mount(): void + { + $allServers = Server::ownedByCurrentTeamCached(); + + $this->servers = $allServers + ->filter(fn (Server $server) => $server->isMetricsEnabled()) + ->values(); + + $this->serverOptions = $this->servers + ->mapWithKeys(fn (Server $server) => [$server->uuid => $server->name]) + ->all(); + + if ($this->serverUuid !== '' && ! array_key_exists($this->serverUuid, $this->serverOptions)) { + $this->serverUuid = ''; + } + } + + public function placeholder(): View + { + return view('livewire.fleet.metrics-placeholder'); + } + + public function render(): View + { + return view('livewire.fleet.metrics'); + } +} diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index 251628c496..0722bdd076 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -95,6 +95,14 @@ Analytics +
  • + + + Metrics + +
  • @can('canAccessTerminal')
  • + + diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php new file mode 100644 index 0000000000..69255b779a --- /dev/null +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -0,0 +1,14 @@ +
    +
    +

    Metrics

    +

    Resource usage across your servers.

    +
    + + @if ($servers->isEmpty()) + + + + + + @endif +
    diff --git a/routes/web.php b/routes/web.php index 2a0b66a2ac..0a42e7386d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,7 @@ use App\Livewire\Destination\Index as DestinationIndex; use App\Livewire\Destination\Resources as DestinationResources; use App\Livewire\Destination\Show as DestinationShow; use App\Livewire\Dev\LivewireRequestFailurePreview; +use App\Livewire\Fleet\Metrics as FleetMetrics; use App\Livewire\ForcePasswordReset; use App\Livewire\Notifications\Discord as NotificationDiscord; use App\Livewire\Notifications\Email as NotificationEmail; @@ -161,6 +162,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/', Dashboard::class)->name('dashboard'); Route::get('/analytics', Analytics::class)->name('analytics'); + Route::get('/metrics', FleetMetrics::class)->name('metrics'); Route::get('/admin', AdminIndex::class)->name('admin.index'); Route::get('/onboarding', BoardingIndex::class)->name('onboarding'); diff --git a/tests/Feature/Livewire/Fleet/MetricsPageTest.php b/tests/Feature/Livewire/Fleet/MetricsPageTest.php new file mode 100644 index 0000000000..ad44bf6d72 --- /dev/null +++ b/tests/Feature/Livewire/Fleet/MetricsPageTest.php @@ -0,0 +1,33 @@ + InstanceSettings::query()->create(['id' => 0])); + $user = User::factory()->create(); + $team = Team::factory()->create(); + $user->teams()->attach($team, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + $this->team = $team; +}); + +it('renders the metrics page with an empty state when no server has metrics enabled', function () { + $component = loadLazy(Livewire::test(Metrics::class)); + + $component + ->assertOk() + ->assertSee('Metrics') + ->assertSee('Enable metrics on a server'); +}); + +it('registers the metrics route', function () { + expect(route('metrics'))->toEndWith('/metrics'); +}); From f950f9058bbefea79017f145ccc6533655728c02 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 07:54:57 +0000 Subject: [PATCH 02/19] feat(metrics): add pressure threshold helper --- app/Support/Metrics/PressureLevel.php | 39 ++++++++++++++++++++++++ tests/Unit/Metrics/PressureLevelTest.php | 22 +++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 app/Support/Metrics/PressureLevel.php create mode 100644 tests/Unit/Metrics/PressureLevelTest.php diff --git a/app/Support/Metrics/PressureLevel.php b/app/Support/Metrics/PressureLevel.php new file mode 100644 index 0000000000..2904c56504 --- /dev/null +++ b/app/Support/Metrics/PressureLevel.php @@ -0,0 +1,39 @@ += $error => 'error', + $percent >= $warn => 'warning', + default => 'ok', + }; + } + + public static function textClass(string $level): string + { + return match ($level) { + 'error' => 'text-red-500 dark:text-red-400', + 'warning' => 'text-orange-500 dark:text-warning', + default => 'text-black dark:text-fg', + }; + } + + public static function barClass(string $level): string + { + return match ($level) { + 'error' => 'bg-red-500 dark:bg-red-400', + 'warning' => 'bg-orange-500 dark:bg-warning', + default => 'bg-neutral-400 dark:bg-white/30', + }; + } +} diff --git a/tests/Unit/Metrics/PressureLevelTest.php b/tests/Unit/Metrics/PressureLevelTest.php new file mode 100644 index 0000000000..e2c922a216 --- /dev/null +++ b/tests/Unit/Metrics/PressureLevelTest.php @@ -0,0 +1,22 @@ +toBe('ok'); + expect(PressureLevel::for(70, 'cpu'))->toBe('warning'); + expect(PressureLevel::for(89.9, 'memory'))->toBe('warning'); + expect(PressureLevel::for(90, 'memory'))->toBe('error'); +}); + +it('classifies disk pressure at 80/90 cutoffs', function () { + expect(PressureLevel::for(75, 'disk'))->toBe('ok'); + expect(PressureLevel::for(80, 'disk'))->toBe('warning'); + expect(PressureLevel::for(95, 'disk'))->toBe('error'); +}); + +it('treats a null percent as ok and maps levels to classes', function () { + expect(PressureLevel::for(null, 'cpu'))->toBe('ok'); + expect(PressureLevel::textClass('error'))->toContain('text-red-500'); + expect(PressureLevel::barClass('warning'))->toContain('bg-orange-500'); +}); From 30aadf03672bdf945cee47d610b33db6c9e5518a Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 07:55:29 +0000 Subject: [PATCH 03/19] feat(metrics): aggregate fleet KPIs with weighted math --- app/Services/FleetMetricsAggregator.php | 92 +++++++++++++++++++ .../Services/FleetMetricsAggregatorTest.php | 69 ++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 app/Services/FleetMetricsAggregator.php create mode 100644 tests/Unit/Services/FleetMetricsAggregatorTest.php 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(); +}); From bc3ec590d01d94b106928ba4977173913c55e512 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 07:55:50 +0000 Subject: [PATCH 04/19] feat(metrics): add bucket series merge and container ranking --- app/Services/FleetMetricsAggregator.php | 44 +++++++++++++++++++ .../FleetMetricsAggregatorRankingTest.php | 33 ++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 tests/Unit/Services/FleetMetricsAggregatorRankingTest.php diff --git a/app/Services/FleetMetricsAggregator.php b/app/Services/FleetMetricsAggregator.php index 72f6ddb10b..29e0459d3b 100644 --- a/app/Services/FleetMetricsAggregator.php +++ b/app/Services/FleetMetricsAggregator.php @@ -61,6 +61,50 @@ class FleetMetricsAggregator ]; } + /** + * @param array> $seriesPerServer + * @return array + */ + public static function sumSeriesByBucket(array $seriesPerServer, string $mode): array + { + $totals = []; + $counts = []; + + foreach ($seriesPerServer as $series) { + foreach ($series as [$ts, $value]) { + $totals[$ts] = ($totals[$ts] ?? 0.0) + (float) $value; + $counts[$ts] = ($counts[$ts] ?? 0) + 1; + } + } + + ksort($totals); + + $out = []; + foreach ($totals as $ts => $sum) { + $out[] = [(int) $ts, $mode === 'avg' ? round($sum / $counts[$ts], 2) : round($sum, 2)]; + } + + return $out; + } + + /** + * @param array> $containers + * @return array> + */ + public static function rankContainers(array $containers, string $metric, int $limit = 20): array + { + $key = match ($metric) { + 'memory' => 'memUsed', + 'disk' => 'diskBytes', + 'network' => 'net', + default => 'cpu', + }; + + usort($containers, fn ($a, $b) => ($b[$key] ?? 0) <=> ($a[$key] ?? 0)); + + return array_slice($containers, 0, $limit); + } + /** * @param array> $rows * @return array diff --git a/tests/Unit/Services/FleetMetricsAggregatorRankingTest.php b/tests/Unit/Services/FleetMetricsAggregatorRankingTest.php new file mode 100644 index 0000000000..6b6fe2ee1e --- /dev/null +++ b/tests/Unit/Services/FleetMetricsAggregatorRankingTest.php @@ -0,0 +1,33 @@ +toBe([[1000, 3.0], [2000, 5.0], [3000, 9.0]]); +}); + +it('averages series across servers per bucket', function () { + $merged = FleetMetricsAggregator::sumSeriesByBucket([ + [[1000, 20.0]], + [[1000, 40.0]], + ], 'avg'); + + expect($merged)->toBe([[1000, 30.0]]); +}); + +it('ranks containers by the selected metric and caps the list', function () { + $containers = [ + ['id' => 'a', 'cpu' => 10.0, 'memUsed' => 500, 'diskBytes' => 5, 'net' => 1.0], + ['id' => 'b', 'cpu' => 90.0, 'memUsed' => 100, 'diskBytes' => 9, 'net' => 2.0], + ['id' => 'c', 'cpu' => 50.0, 'memUsed' => 900, 'diskBytes' => 1, 'net' => 3.0], + ]; + + expect(array_column(FleetMetricsAggregator::rankContainers($containers, 'cpu', 2), 'id'))->toBe(['b', 'c']); + expect(array_column(FleetMetricsAggregator::rankContainers($containers, 'memory'), 'id'))->toBe(['c', 'a', 'b']); + expect(array_column(FleetMetricsAggregator::rankContainers($containers, 'network'), 'id'))->toBe(['c', 'b', 'a']); +}); From ba8ed4e8fb2d80415ef434ca6e7d05e67dac0da6 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 07:56:44 +0000 Subject: [PATCH 05/19] feat(metrics): add SentinelMetricsClient with graceful degradation --- app/Services/SentinelMetricsClient.php | 199 ++++++++++++++++++ .../Services/SentinelMetricsClientTest.php | 106 ++++++++++ 2 files changed, 305 insertions(+) create mode 100644 app/Services/SentinelMetricsClient.php create mode 100644 tests/Unit/Services/SentinelMetricsClientTest.php diff --git a/app/Services/SentinelMetricsClient.php b/app/Services/SentinelMetricsClient.php new file mode 100644 index 0000000000..5f27f1e564 --- /dev/null +++ b/app/Services/SentinelMetricsClient.php @@ -0,0 +1,199 @@ + now()->subDays(7), + '30d' => now()->subDays(30), + default => now()->subDay(), + }; + + return $from->toIso8601ZuluString(); + } + + /** + * @return array|null + */ + public function summary(): ?array + { + try { + $json = json_decode($this->raw($this->url('/summary')), true); + } catch (\Throwable) { + return null; + } + + if (! is_array($json)) { + return null; + } + + $disk = $this->rootMount($json['disk'] ?? []); + + return [ + 'online' => true, + 'cpu' => isset($json['cpu']['percent']) ? (float) $json['cpu']['percent'] : null, + 'memUsed' => isset($json['memory']['used']) ? (int) $json['memory']['used'] : null, + 'memTotal' => isset($json['memory']['total']) ? (int) $json['memory']['total'] : null, + 'diskUsed' => $disk['used'], + 'diskTotal' => $disk['total'], + 'diskPercent' => $disk['percent'], + 'load1' => isset($json['load']['load1']) ? (float) $json['load']['load1'] : null, + 'netRx' => isset($json['network']['rxBytesPerSec']) ? (float) $json['network']['rxBytesPerSec'] : null, + 'netTx' => isset($json['network']['txBytesPerSec']) ? (float) $json['network']['txBytesPerSec'] : null, + ]; + } + + /** + * @return array> + */ + public function containersCurrent(): array + { + try { + $rows = json_decode($this->raw($this->url('/containers/current')), true); + } catch (\Throwable) { + return []; + } + + if (! is_array($rows)) { + return []; + } + + return array_values(array_map(function ($c) { + $disk = ($c['disk']['writableLayer'] ?? 0) + ($c['disk']['volumesTotal'] ?? 0); + $net = ($c['network']['rxBytesPerSec'] ?? 0) + ($c['network']['txBytesPerSec'] ?? 0); + + return [ + 'id' => (string) ($c['id'] ?? ''), + 'cpu' => isset($c['cpu']['percent']) ? (float) $c['cpu']['percent'] : null, + 'memUsed' => isset($c['memory']['used']) ? (int) $c['memory']['used'] : null, + 'memPercent' => isset($c['memory']['usedPercent']) ? (float) $c['memory']['usedPercent'] : null, + 'diskBytes' => isset($c['disk']) ? (int) $disk : null, + 'net' => isset($c['network']) ? (float) $net : null, + ]; + }, $rows)); + } + + /** + * @return array + */ + public function history(string $metric, string $from): array + { + [$path, $field] = match ($metric) { + 'memory' => ['/memory/history', 'used'], + 'disk' => ['/disk/history', 'usedPercent'], + 'load' => ['/load/history', 'load1'], + default => ['/cpu/history', 'percent'], + }; + + return $this->seriesRows($this->url($path, ['from' => $from]), $field); + } + + /** + * @return array{rx: array, tx: array} + */ + public function networkHistory(string $from): array + { + $url = $this->url('/network/history', ['from' => $from]); + + return [ + 'rx' => $this->seriesRows($url, 'rxBytesPerSec'), + 'tx' => $this->seriesRows($url, 'txBytesPerSec'), + ]; + } + + /** + * @return array + */ + private function seriesRows(string $url, string $field): array + { + try { + $rows = json_decode($this->raw($url), true); + } catch (\Throwable) { + return []; + } + + if (! is_array($rows)) { + return []; + } + + return array_values(array_map( + fn ($r) => [(int) ($r['time'] ?? 0), (float) ($r[$field] ?? 0)], + $rows + )); + } + + /** + * @param array> $mounts + * @return array{used: ?int, total: ?int, percent: ?float} + */ + private function rootMount(array $mounts): array + { + if ($mounts === []) { + return ['used' => null, 'total' => null, 'percent' => null]; + } + + $root = collect($mounts)->firstWhere('mount', '/') ?? $mounts[0]; + + return [ + 'used' => (int) ($root['used'] ?? 0), + 'total' => (int) ($root['total'] ?? 0), + 'percent' => (float) ($root['usedPercent'] ?? 0), + ]; + } + + /** + * @param array $query + */ + private function url(string $path, array $query = []): string + { + $qs = empty($query) ? '' : '?'.str_replace('%3A', ':', http_build_query($query)); + + return $this->base.$path.$qs; + } + + private function cacheKey(string $url): string + { + return 'metrics:'.$this->server->uuid.':'.md5($url); + } + + protected function raw(string $url): string + { + return Cache::remember($this->cacheKey($url), 60, fn () => $this->guard($this->remoteFetch($url))); + } + + protected function remoteFetch(string $url): string + { + $token = $this->server->settings->ensureValidSentinelToken(); + + return instant_remote_process( + ["docker exec coolify-sentinel sh -c 'curl -s -H \"Authorization: Bearer {$token}\" \"{$url}\"'"], + $this->server, + false + ); + } + + private function guard(string $response): string + { + $payload = json_decode($response, true); + + if ($payload === null) { + throw new \RuntimeException('Metrics returned an invalid response.'); + } + + if (is_array($payload) && array_key_exists('error', $payload)) { + throw new \RuntimeException('Metrics request failed.'); + } + + return $response; + } +} diff --git a/tests/Unit/Services/SentinelMetricsClientTest.php b/tests/Unit/Services/SentinelMetricsClientTest.php new file mode 100644 index 0000000000..1b1088169e --- /dev/null +++ b/tests/Unit/Services/SentinelMetricsClientTest.php @@ -0,0 +1,106 @@ + substring => body */ + public array $bodies = []; + + /** @var array */ + public array $failing = []; + + protected function raw(string $url): string + { + foreach ($this->failing as $needle) { + if (str_contains($url, $needle)) { + throw new RuntimeException('boom'); + } + } + foreach ($this->bodies as $needle => $body) { + if (str_contains($url, $needle)) { + return $body; + } + } + + return '[]'; + } +} + +function fakeMetricsClient(): FakeMetricsClient +{ + $server = Server::factory()->make(['uuid' => 'srv-uuid']); + + return new FakeMetricsClient($server); +} + +it('normalizes the host summary, choosing the root disk mount', function () { + $client = fakeMetricsClient(); + $client->bodies = [ + '/summary' => json_encode([ + 'cpu' => ['time' => 't', 'percent' => 42.5], + 'memory' => ['time' => 't', 'total' => 1000, 'used' => 250, 'usedPercent' => 25.0], + 'disk' => [ + ['time' => 't', 'mount' => '/boot', 'total' => 10, 'used' => 9, 'usedPercent' => 90.0], + ['time' => 't', 'mount' => '/', 'total' => 100, 'used' => 40, 'usedPercent' => 40.0], + ], + 'network' => ['time' => 't', 'rxBytesPerSec' => 100.0, 'txBytesPerSec' => 20.0], + 'load' => ['time' => 't', 'load1' => 1.5, 'load5' => 1.0, 'load15' => 0.5], + ]), + ]; + + $summary = $client->summary(); + + expect($summary['cpu'])->toBe(42.5); + expect($summary['memUsed'])->toBe(250); + expect($summary['diskPercent'])->toBe(40.0); // root mount, not /boot + expect($summary['netRx'])->toBe(100.0); + expect($summary['load1'])->toBe(1.5); +}); + +it('returns null summary when the endpoint fails', function () { + $client = fakeMetricsClient(); + $client->failing = ['/summary']; + + expect($client->summary())->toBeNull(); +}); + +it('normalizes containers, parsing the stringified cpu percent', function () { + $client = fakeMetricsClient(); + $client->bodies = [ + '/containers/current' => json_encode([ + ['id' => 'app-a', 'cpu' => ['time' => 't', 'percent' => '12.5'], + 'memory' => ['used' => 500, 'usedPercent' => 5.0], + 'disk' => ['writableLayer' => 3, 'volumesTotal' => 7], + 'network' => ['rxBytesPerSec' => 4.0, 'txBytesPerSec' => 1.0]], + ]), + ]; + + $rows = $client->containersCurrent(); + + expect($rows[0]['id'])->toBe('app-a'); + expect($rows[0]['cpu'])->toBe(12.5); + expect($rows[0]['memUsed'])->toBe(500); + expect($rows[0]['diskBytes'])->toBe(10); // writableLayer + volumesTotal + expect($rows[0]['net'])->toBe(5.0); // rx + tx +}); + +it('returns an empty container list when the endpoint 404s', function () { + $client = fakeMetricsClient(); + $client->failing = ['/containers/current']; + + expect($client->containersCurrent())->toBe([]); +}); + +it('maps cpu history to [time, value] pairs', function () { + $client = fakeMetricsClient(); + $client->bodies = [ + '/cpu/history' => json_encode([ + ['time' => 1000, 'percent' => '10'], + ['time' => 2000, 'percent' => '20'], + ]), + ]; + + expect($client->history('cpu', 'from'))->toBe([[1000, 10.0], [2000, 20.0]]); +}); From fb021c3d9d80d5ace1cd555dabfc686861e12242 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 07:58:55 +0000 Subject: [PATCH 06/19] feat(metrics): populate fleet KPIs, server rows, and container ranking --- app/Livewire/Fleet/Metrics.php | 149 ++++++++++++++++++ .../Livewire/Fleet/MetricsDataTest.php | 128 +++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 tests/Feature/Livewire/Fleet/MetricsDataTest.php diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index 8cbefffd00..63dfd5605a 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -2,7 +2,10 @@ namespace App\Livewire\Fleet; +use App\Models\Application; use App\Models\Server; +use App\Services\FleetMetricsAggregator; +use App\Services\SentinelMetricsClient; use Illuminate\Contracts\View\View; use Illuminate\Support\Collection; use Livewire\Attributes\Lazy; @@ -26,8 +29,23 @@ class Metrics extends Component #[Url(as: 'server')] public string $serverUuid = ''; + #[Url(as: 'cmetric')] + public string $containerMetric = 'cpu'; + public bool $live = false; + /** @var array */ + public array $kpis = []; + + /** @var array> */ + public array $serverRows = []; + + /** @var array> */ + public array $topContainers = []; + + /** @var array */ + protected array $containerMetaCache = []; + public function mount(): void { $allServers = Server::ownedByCurrentTeamCached(); @@ -43,6 +61,137 @@ class Metrics extends Component if ($this->serverUuid !== '' && ! array_key_exists($this->serverUuid, $this->serverOptions)) { $this->serverUuid = ''; } + + if ($this->servers->isNotEmpty()) { + $this->loadData(); + } + } + + public function updatedServerUuid(): void + { + $this->loadData(); + } + + public function updatedContainerMetric(): void + { + $this->loadData(); + } + + public function setRange(string $range): void + { + $this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h'; + $this->loadData(); + } + + /** + * @return Collection + */ + protected function targetServers(): Collection + { + if ($this->serverUuid !== '') { + return $this->servers->filter(fn (Server $s) => $s->uuid === $this->serverUuid)->values(); + } + + return $this->servers; + } + + public function loadData(): void + { + if ($this->servers->isEmpty()) { + return; + } + + $rows = []; + $containers = []; + + foreach ($this->targetServers() as $server) { + $client = $this->metricsClient($server); + + try { + $summary = $client->summary(); + } catch (\Throwable) { + $summary = null; + } + + if ($summary === null) { + $rows[] = $this->offlineRow($server); + + continue; + } + + $current = $client->containersCurrent(); + $rows[] = array_merge( + ['uuid' => $server->uuid, 'name' => $server->name, 'online' => true, 'containers' => count($current)], + $summary + ); + + foreach ($current as $c) { + $meta = $this->resolveContainer($server, $c['id']); + $containers[] = array_merge($c, ['server' => $server->name, 'name' => $meta['name'], 'link' => $meta['link']]); + } + } + + $this->serverRows = $rows; + $this->kpis = FleetMetricsAggregator::fleetKpis($rows); + $this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric); + + $this->dispatch("refreshChartData-{$this->chartId}", $this->chartPayload()); + } + + /** + * @return array + */ + protected function offlineRow(Server $server): array + { + return [ + 'uuid' => $server->uuid, 'name' => $server->name, 'online' => false, + 'cpu' => null, 'memUsed' => null, 'memTotal' => null, + 'diskUsed' => null, 'diskTotal' => null, 'diskPercent' => null, + 'load1' => null, 'netRx' => null, 'netTx' => null, 'containers' => null, + ]; + } + + protected function metricsClient(Server $server): SentinelMetricsClient + { + return app(SentinelMetricsClient::class, ['server' => $server]); + } + + /** + * Resolve a Sentinel container id (the resource uuid Sentinel keys by) to a + * team-owned resource name + metrics link, memoized. Shows the raw id when no + * team-owned resource matches, so a Sentinel id never discloses another team's name. + * + * @return array{name: string, link: ?string} + */ + protected function resolveContainer(Server $server, string $id): array + { + if (isset($this->containerMetaCache[$id])) { + return $this->containerMetaCache[$id]; + } + + $app = Application::ownedByCurrentTeam()->with('environment.project')->whereUuid($id)->first(); + + $link = null; + if ($app && data_get($app, 'environment.project.uuid')) { + $link = route('project.application.metrics', [ + 'project_uuid' => $app->environment->project->uuid, + 'environment_uuid' => $app->environment->uuid, + 'application_uuid' => $app->uuid, + ]); + } + + return $this->containerMetaCache[$id] = [ + 'name' => $app?->name ?? $id, + 'link' => $link, + ]; + } + + /** + * @return array + */ + protected function chartPayload(): array + { + return ['range' => $this->range]; } public function placeholder(): View diff --git a/tests/Feature/Livewire/Fleet/MetricsDataTest.php b/tests/Feature/Livewire/Fleet/MetricsDataTest.php new file mode 100644 index 0000000000..1ef9cd5b32 --- /dev/null +++ b/tests/Feature/Livewire/Fleet/MetricsDataTest.php @@ -0,0 +1,128 @@ + serverUuid => summary */ + public static array $summaries = []; + + /** @var array serverUuid => containers */ + public static array $containers = []; + + public function summary(): ?array + { + return self::$summaries[$this->server->uuid] ?? null; + } + + public function containersCurrent(): array + { + return self::$containers[$this->server->uuid] ?? []; + } + + public function history(string $metric, string $from): array + { + return []; + } + + public function networkHistory(string $from): array + { + return ['rx' => [], 'tx' => []]; + } +} + +class TestableMetrics extends Metrics +{ + protected function metricsClient(Server $server): SentinelMetricsClient + { + return new StubMetricsClient($server); + } +} + +beforeEach(function () { + StubMetricsClient::$summaries = []; + StubMetricsClient::$containers = []; + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + $user = User::factory()->create(); + $team = Team::factory()->create(); + $user->teams()->attach($team, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $this->team = $team; +}); + +function metricsServer($team, $privateKey): Server +{ + $server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]); + $server->settings->update(['is_metrics_enabled' => true]); + + return $server; +} + +function onlineSummary(array $overrides = []): array +{ + return array_merge([ + 'online' => true, 'cpu' => 30.0, 'memUsed' => 100, 'memTotal' => 200, + 'diskUsed' => 10, 'diskTotal' => 100, 'diskPercent' => 10.0, + 'load1' => 0.4, 'netRx' => 5.0, 'netTx' => 1.0, + ], $overrides); +} + +it('keeps an unreachable server in the table but excludes it from KPIs', function () { + $ok = metricsServer($this->team, $this->privateKey); + $dead = metricsServer($this->team, $this->privateKey); + + StubMetricsClient::$summaries = [ + $ok->uuid => onlineSummary(['cpu' => 30.0]), + $dead->uuid => null, + ]; + + $component = Livewire::test(TestableMetrics::class); + + expect($component->get('serverRows'))->toHaveCount(2); + + $deadRow = collect($component->get('serverRows'))->firstWhere('uuid', $dead->uuid); + expect($deadRow['online'])->toBeFalse(); + expect($deadRow['cpu'])->toBeNull(); + + expect($component->get('kpis')['serversOnline'])->toBe(1); + expect($component->get('kpis')['cpuAvg'])->toBe(30.0); +}); + +it('ranks containers across servers by the selected metric', function () { + $s = metricsServer($this->team, $this->privateKey); + + StubMetricsClient::$summaries = [$s->uuid => onlineSummary(['diskPercent' => 50.0])]; + StubMetricsClient::$containers = [$s->uuid => [ + ['id' => 'low', 'cpu' => 5.0, 'memUsed' => 10, 'memPercent' => 1.0, 'diskBytes' => 1, 'net' => 0.0], + ['id' => 'high', 'cpu' => 88.0, 'memUsed' => 20, 'memPercent' => 2.0, 'diskBytes' => 2, 'net' => 0.0], + ]]; + + $component = Livewire::test(TestableMetrics::class); + + expect(array_column($component->get('topContainers'), 'id')[0])->toBe('high'); +}); + +it('cannot see another team’s servers', function () { + $otherTeam = Team::factory()->create(); + // Reuse the existing key id: the factory's private key is hardcoded, so a second + // PrivateKey::create collides on fingerprint. Only the server's team_id matters here. + $server = Server::factory()->create(['team_id' => $otherTeam->id, 'private_key_id' => $this->privateKey->id]); + $server->settings->update(['is_metrics_enabled' => true]); + + $component = Livewire::test(TestableMetrics::class); + + expect($component->get('serverRows'))->toHaveCount(0); +}); From 5273a4c035bb1ce38d214c3092bac933bdb59e2a Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:01:11 +0000 Subject: [PATCH 07/19] feat(metrics): build and dispatch fleet trend chart payloads --- .../Concerns/BuildsMetricsChartPayload.php | 25 +++++++ app/Livewire/Fleet/Metrics.php | 23 +++--- .../Fleet/MetricsChartPayloadTest.php | 73 +++++++++++++++++++ 3 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 app/Livewire/Concerns/BuildsMetricsChartPayload.php create mode 100644 tests/Feature/Livewire/Fleet/MetricsChartPayloadTest.php diff --git a/app/Livewire/Concerns/BuildsMetricsChartPayload.php b/app/Livewire/Concerns/BuildsMetricsChartPayload.php new file mode 100644 index 0000000000..c660ed49d3 --- /dev/null +++ b/app/Livewire/Concerns/BuildsMetricsChartPayload.php @@ -0,0 +1,25 @@ + + */ + protected function buildChartPayload(array $seriesByServer): array + { + return [ + 'range' => $this->range, + 'cpu' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['cpu'], 'avg'), + 'memory' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['memory'], 'sum'), + 'disk' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['disk'], 'avg'), + 'load' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['load'], 'avg'), + 'networkRx' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkRx'], 'sum'), + 'networkTx' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkTx'], 'sum'), + ]; + } +} diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index 63dfd5605a..1de503492f 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -2,6 +2,7 @@ namespace App\Livewire\Fleet; +use App\Livewire\Concerns\BuildsMetricsChartPayload; use App\Models\Application; use App\Models\Server; use App\Services\FleetMetricsAggregator; @@ -15,6 +16,8 @@ use Livewire\Component; #[Lazy] class Metrics extends Component { + use BuildsMetricsChartPayload; + public string $chartId = 'fleet-metrics'; /** Metrics-enabled servers owned by the current team. */ @@ -103,6 +106,8 @@ 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); @@ -129,13 +134,21 @@ class Metrics extends Component $meta = $this->resolveContainer($server, $c['id']); $containers[] = array_merge($c, ['server' => $server->name, 'name' => $meta['name'], 'link' => $meta['link']]); } + + $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['networkRx'][] = $net['rx']; + $series['networkTx'][] = $net['tx']; } $this->serverRows = $rows; $this->kpis = FleetMetricsAggregator::fleetKpis($rows); $this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric); - $this->dispatch("refreshChartData-{$this->chartId}", $this->chartPayload()); + $this->dispatch("refreshChartData-{$this->chartId}", $this->buildChartPayload($series)); } /** @@ -186,14 +199,6 @@ class Metrics extends Component ]; } - /** - * @return array - */ - protected function chartPayload(): array - { - return ['range' => $this->range]; - } - public function placeholder(): View { return view('livewire.fleet.metrics-placeholder'); diff --git a/tests/Feature/Livewire/Fleet/MetricsChartPayloadTest.php b/tests/Feature/Livewire/Fleet/MetricsChartPayloadTest.php new file mode 100644 index 0000000000..0dc92d383d --- /dev/null +++ b/tests/Feature/Livewire/Fleet/MetricsChartPayloadTest.php @@ -0,0 +1,73 @@ + true, 'cpu' => 10.0, 'memUsed' => 1, 'memTotal' => 2, + 'diskUsed' => 1, 'diskTotal' => 2, 'diskPercent' => 50.0, 'load1' => 0.1, 'netRx' => 0.0, 'netTx' => 0.0]; + } + + public function containersCurrent(): array + { + return []; + } + + public function history(string $metric, string $from): array + { + return [[1000, 10.0], [2000, 20.0]]; + } + + public function networkHistory(string $from): array + { + return ['rx' => [[1000, 5.0]], 'tx' => [[1000, 1.0]]]; + } +} + +class ChartTestableMetrics extends Metrics +{ + protected function metricsClient(Server $server): SentinelMetricsClient + { + return new ChartStubClient($server); + } +} + +beforeEach(function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + $user = User::factory()->create(); + $team = Team::factory()->create(); + $user->teams()->attach($team, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $this->team = $team; +}); + +it('dispatches a chart payload with every trend series', function () { + $s = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]); + $s->settings->update(['is_metrics_enabled' => true]); + + Livewire::test(ChartTestableMetrics::class) + ->call('loadData') + ->assertDispatched('refreshChartData-fleet-metrics', function ($event, $params) { + $payload = $params[0]; + + // Livewire JSON-serializes event params, so whole floats arrive as ints; + // compare loosely (10 == 10.0). + return $payload['cpu'] == [[1000, 10], [2000, 20]] + && $payload['networkRx'] == [[1000, 5]] + && array_key_exists('load', $payload); + }); +}); From 6b9086795f07107a0d846c9a2a7a3e0df2a63ed1 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:05:41 +0000 Subject: [PATCH 08/19] feat(metrics): render fleet KPIs, tables, and trend charts --- app/Livewire/Fleet/Metrics.php | 18 ++ .../livewire/fleet/_usage-cell.blade.php | 12 + .../views/livewire/fleet/metrics.blade.php | 210 +++++++++++++++++- 3 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 resources/views/livewire/fleet/_usage-cell.blade.php diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index 1de503492f..c47d282033 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -46,6 +46,8 @@ class Metrics extends Component /** @var array> */ public array $topContainers = []; + public ?string $lastUpdatedAt = null; + /** @var array */ protected array $containerMetaCache = []; @@ -86,6 +88,21 @@ class Metrics extends Component $this->loadData(); } + public function isLivePollable(): bool + { + return $this->live && $this->range === '24h'; + } + + /** + * Re-pull within the 60s cache TTL. The TTL bounds staleness, so a refresh is a + * cheap cache-hit reload rather than a global cache flush (matches the Analytics + * live toggle, which also relies on the TTL). + */ + public function refresh(): void + { + $this->loadData(); + } + /** * @return Collection */ @@ -147,6 +164,7 @@ class Metrics extends Component $this->serverRows = $rows; $this->kpis = FleetMetricsAggregator::fleetKpis($rows); $this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric); + $this->lastUpdatedAt = now()->toIso8601String(); $this->dispatch("refreshChartData-{$this->chartId}", $this->buildChartPayload($series)); } diff --git a/resources/views/livewire/fleet/_usage-cell.blade.php b/resources/views/livewire/fleet/_usage-cell.blade.php new file mode 100644 index 0000000000..55c233996a --- /dev/null +++ b/resources/views/livewire/fleet/_usage-cell.blade.php @@ -0,0 +1,12 @@ +@php + $level = \App\Support\Metrics\PressureLevel::for($percent, $kind); + $width = $percent === null ? 0 : min(100, round($percent, 1)); +@endphp +
    + + {{ $percent === null ? '-' : round($percent).'%' }} + + +
    diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index 69255b779a..f3a5857126 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -1,7 +1,33 @@
    -
    -

    Metrics

    -

    Resource usage across your servers.

    + {{-- Header + controls --}} +
    +
    +

    Metrics

    +

    Resource usage across your servers.

    +
    + @if ($servers->isNotEmpty()) +
    + @if ($lastUpdatedAt) + + @endif + +
    + @foreach (['24h', '7d', '30d'] as $r) + + @endforeach +
    + +
    + @endif
    @if ($servers->isEmpty()) @@ -10,5 +36,183 @@ + @else + {{-- KPI tiles --}} +
    +
    + Servers online + {{ $kpis['serversOnline'] ?? 0 }}/{{ $kpis['serversTotal'] ?? 0 }} + @if (($kpis['needsAttention'] ?? 0) > 0) + {{ $kpis['needsAttention'] }} needs attention + @endif +
    +
    + Fleet CPU @if ($kpis['cpuApproximate'] ?? false)~@endif + {{ round($kpis['cpuAvg'] ?? 0) }}% + @if ($kpis['cpuBusiest'] ?? null){{ $kpis['cpuBusiest']['name'] }}: {{ round($kpis['cpuBusiest']['percent']) }}%@endif +
    +
    + Fleet memory + {{ round($kpis['memPercent'] ?? 0) }}% + {{ formatBytes($kpis['memUsed'] ?? 0) }} / {{ formatBytes($kpis['memTotal'] ?? 0) }} +
    +
    + Fleet disk + {{ round($kpis['diskPercent'] ?? 0) }}% + {{ formatBytes($kpis['diskUsed'] ?? 0) }} / {{ formatBytes($kpis['diskTotal'] ?? 0) }} +
    +
    + Fleet network + {{ formatBytes(($kpis['netRx'] ?? 0) + ($kpis['netTx'] ?? 0)) }}/s + rx {{ formatBytes($kpis['netRx'] ?? 0) }}/s tx {{ formatBytes($kpis['netTx'] ?? 0) }}/s +
    +
    + Containers + {{ compactNumber($kpis['containers'] ?? 0) }} +
    +
    + + {{-- Trend charts --}} +
    + @foreach (['cpu' => 'CPU %', 'memory' => 'Memory used', 'network' => 'Network', 'load' => 'Load average', 'disk' => 'Disk %'] as $key => $label) + +
    +
    +
    +
    + @endforeach +
    + + {{-- Servers table --}} + + + + + + + + + @foreach (collect($serverRows)->sortByDesc('cpu') as $row) + + + + + + + + + + @endforeach + +
    ServerCPUMemoryDiskLoadNetworkContainers
    + {{ $row['name'] }} + @unless ($row['online']) + + @endunless + @include('livewire.fleet._usage-cell', ['percent' => $row['cpu'], 'kind' => 'cpu'])@include('livewire.fleet._usage-cell', ['percent' => ($row['memTotal'] ?? 0) ? round($row['memUsed'] / $row['memTotal'] * 100, 1) : null, 'kind' => 'memory'])@include('livewire.fleet._usage-cell', ['percent' => $row['diskPercent'], 'kind' => 'disk']){{ $row['load1'] === null ? '-' : number_format($row['load1'], 2) }}{{ $row['netRx'] === null ? '-' : formatBytes($row['netRx'] + $row['netTx']).'/s' }}{{ $row['containers'] ?? '-' }}
    +
    + + {{-- Hottest containers --}} + + +
    + @foreach (['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'] as $m => $mlabel) + + @endforeach +
    +
    + @if (empty($topContainers)) + + @else + + + + @foreach ($topContainers as $c) + + + + + + @endforeach + +
    ContainerServer{{ ucfirst($containerMetric) }}
    @if ($c['link']){{ $c['name'] }}@else{{ $c['name'] }}@endif{{ $c['server'] }} + @switch($containerMetric) + @case('memory'){{ formatBytes($c['memUsed'] ?? 0) }}@break + @case('disk'){{ formatBytes($c['diskBytes'] ?? 0) }}@break + @case('network'){{ formatBytes($c['net'] ?? 0) }}/s@break + @default{{ round($c['cpu'] ?? 0) }}% + @endswitch +
    + @endif +
    @endif + + @script + + @endscript
    From c2310a74659741eb6c1b2d99ebda71e1d489e747 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:06:08 +0000 Subject: [PATCH 09/19] docs(metrics): refresh DESIGN for disk/network/load history and fleet page --- DESIGN.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 53b058ddef..5a6d914860 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -746,10 +746,14 @@ Charts follow the application metrics implementation: - downsample long time ranges before rendering. Only add a metric if Sentinel exposes historical data for it. Current Sentinel -history endpoints store CPU and memory. Root filesystem usage is included in -the periodic push payload for threshold notifications, but it is not stored as -a historical Sentinel metric and has no history endpoint, so it cannot power a -disk-usage graph yet. +history endpoints store CPU, memory, disk, network throughput, and load average +(Sentinel PR #47 added disk, network, and load). Bulk snapshot endpoints +(`/api/summary`, `/api/containers/current`) return the latest host and +per-container values in one call for fleet-wide views. + +The fleet Metrics page (`/metrics`, top-level sidebar) aggregates these across +every team server: KPI tiles, per-metric trend cards, a threshold-colored +servers table, and a fleet-wide hottest-containers list. --- @@ -769,6 +773,7 @@ Use these as implementation references: | Dense environment table and footer | `resources/views/livewire/project/shared/environment-variable/all.blade.php` | | Standard table toolbar controls | `resources/views/components/table/*` | | Application metrics charts | `resources/views/livewire/project/shared/metrics.blade.php` | +| Fleet metrics page | `resources/views/livewire/fleet/metrics.blade.php` | | Browser terminal workspace | `resources/views/livewire/terminal/index.blade.php` | | Layer card | `resources/views/components/application/settings-section.blade.php` | | Custom dropdown | `resources/views/components/forms/listbox.blade.php` | From fbce977dbfb855d03c50b19f0a5e2974eec06689 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:07:01 +0000 Subject: [PATCH 10/19] test(metrics): assert KPI tiles, stale badge, and container toggle render --- .../Livewire/Fleet/MetricsDataTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/Feature/Livewire/Fleet/MetricsDataTest.php b/tests/Feature/Livewire/Fleet/MetricsDataTest.php index 1ef9cd5b32..ee10acade2 100644 --- a/tests/Feature/Livewire/Fleet/MetricsDataTest.php +++ b/tests/Feature/Livewire/Fleet/MetricsDataTest.php @@ -115,6 +115,25 @@ it('ranks containers across servers by the selected metric', function () { expect(array_column($component->get('topContainers'), 'id')[0])->toBe('high'); }); +it('shows the KPI tiles, a stale badge for offline servers, and the container toggle', function () { + $ok = metricsServer($this->team, $this->privateKey); + $ok->update(['name' => 'web-1']); + $dead = metricsServer($this->team, $this->privateKey); + $dead->update(['name' => 'web-2']); + + StubMetricsClient::$summaries = [ + $ok->uuid => onlineSummary(['cpu' => 95.0, 'diskPercent' => 95.0]), + $dead->uuid => null, + ]; + + Livewire::test(TestableMetrics::class) + ->assertSee('web-1') + ->assertSee('web-2') + ->assertSee('Fleet CPU') + ->assertSee('needs attention') + ->assertSee('Offline'); +}); + it('cannot see another team’s servers', function () { $otherTeam = Team::factory()->create(); // Reuse the existing key id: the factory's private key is hardcoded, so a second From 9fced09bd15ec85304d74cbb399962dbf44fae58 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:39:14 +0000 Subject: [PATCH 11/19] fix(metrics): div-grid tables, chart initial render, metrics skeleton, graph icon --- app/Livewire/Fleet/Metrics.php | 6 +- resources/css/app.css | 8 + resources/views/components/navbar.blade.php | 2 +- .../fleet/metrics-placeholder.blade.php | 17 +- .../views/livewire/fleet/metrics.blade.php | 170 ++++++++++-------- .../Livewire/Fleet/MetricsDataTest.php | 2 +- 6 files changed, 123 insertions(+), 82 deletions(-) diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index c47d282033..e29f8314ae 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -46,6 +46,9 @@ class Metrics extends Component /** @var array> */ public array $topContainers = []; + /** @var array Trend series for the charts, also embedded for first paint. */ + public array $chartData = []; + public ?string $lastUpdatedAt = null; /** @var array */ @@ -164,9 +167,10 @@ class Metrics extends Component $this->serverRows = $rows; $this->kpis = FleetMetricsAggregator::fleetKpis($rows); $this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric); + $this->chartData = $this->buildChartPayload($series); $this->lastUpdatedAt = now()->toIso8601String(); - $this->dispatch("refreshChartData-{$this->chartId}", $this->buildChartPayload($series)); + $this->dispatch("refreshChartData-{$this->chartId}", $this->chartData); } /** diff --git a/resources/css/app.css b/resources/css/app.css index a8eef8e2d7..a1d5a7fde1 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -3183,6 +3183,14 @@ input[type="search"]::-webkit-search-results-decoration { grid-template-columns: minmax(9rem, 1.15fr) minmax(14rem, 1.9fr) 7rem 6.5rem 6.5rem 5.5rem; } +.fleet-servers-table-grid { + grid-template-columns: minmax(9rem, 1.4fr) 8rem 8rem 8rem 5rem 8rem 5.5rem; +} + +.fleet-containers-table-grid { + grid-template-columns: minmax(10rem, 1.6fr) minmax(8rem, 1fr) 7rem; +} + .scheduled-executions-table-grid { grid-template-columns: 5.5rem minmax(9rem, 1fr) minmax(7rem, 0.7fr) 8.5rem 5rem minmax(12rem, 1.5fr); } diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index 0722bdd076..2861a4650d 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -99,7 +99,7 @@
    - + Metrics
  • diff --git a/resources/views/livewire/fleet/metrics-placeholder.blade.php b/resources/views/livewire/fleet/metrics-placeholder.blade.php index aee0488f17..f67be9c225 100644 --- a/resources/views/livewire/fleet/metrics-placeholder.blade.php +++ b/resources/views/livewire/fleet/metrics-placeholder.blade.php @@ -1,3 +1,18 @@
    - +
    +

    Metrics

    +

    Resource usage across your servers.

    +
    + + + +
    + @foreach (['CPU', 'Memory used'] as $label) +
    + {{ $label }} + +
    + @endforeach +
    diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index f3a5857126..514ff66d4e 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -19,12 +19,12 @@
    @foreach (['24h', '7d', '30d'] as $r) + class="{{ $range === $r ? 'bg-[var(--coollabs-base)] text-black shadow-sm dark:text-fg' : 'text-neutral-500 hover:text-black dark:hover:text-fg' }} rounded px-2.5 py-1 text-[12px] font-medium transition-colors">{{ $r }} @endforeach
    @endif @@ -33,7 +33,7 @@ @if ($servers->isEmpty()) - + @else @@ -43,13 +43,15 @@ Servers online {{ $kpis['serversOnline'] ?? 0 }}/{{ $kpis['serversTotal'] ?? 0 }} @if (($kpis['needsAttention'] ?? 0) > 0) - {{ $kpis['needsAttention'] }} needs attention + {{ $kpis['needsAttention'] }} need attention + @else + All healthy @endif
    - Fleet CPU @if ($kpis['cpuApproximate'] ?? false)~@endif + Fleet CPU @if ($kpis['cpuApproximate'] ?? false)~@endif {{ round($kpis['cpuAvg'] ?? 0) }}% - @if ($kpis['cpuBusiest'] ?? null){{ $kpis['cpuBusiest']['name'] }}: {{ round($kpis['cpuBusiest']['percent']) }}%@endif + @if ($kpis['cpuBusiest'] ?? null)Busiest: {{ $kpis['cpuBusiest']['name'] }} {{ round($kpis['cpuBusiest']['percent']) }}%@endif
    Fleet memory @@ -64,20 +66,21 @@
    Fleet network {{ formatBytes(($kpis['netRx'] ?? 0) + ($kpis['netTx'] ?? 0)) }}/s - rx {{ formatBytes($kpis['netRx'] ?? 0) }}/s tx {{ formatBytes($kpis['netTx'] ?? 0) }}/s + rx {{ formatBytes($kpis['netRx'] ?? 0) }}/s · tx {{ formatBytes($kpis['netTx'] ?? 0) }}/s
    Containers {{ compactNumber($kpis['containers'] ?? 0) }} + running
    {{-- Trend charts --}}
    - @foreach (['cpu' => 'CPU %', 'memory' => 'Memory used', 'network' => 'Network', 'load' => 'Load average', 'disk' => 'Disk %'] as $key => $label) + @foreach (['cpu' => 'CPU', 'memory' => 'Memory used', 'network' => 'Network throughput', 'load' => 'Load average', 'disk' => 'Disk usage'] as $key => $label)
    -
    +
    @endforeach @@ -85,31 +88,34 @@ {{-- Servers table --}} - - - - - - - - @foreach (collect($serverRows)->sortByDesc('cpu') as $row) - - - - - - - - - - @endforeach - -
    ServerCPUMemoryDiskLoadNetworkContainers
    - {{ $row['name'] }} - @unless ($row['online']) - - @endunless - @include('livewire.fleet._usage-cell', ['percent' => $row['cpu'], 'kind' => 'cpu'])@include('livewire.fleet._usage-cell', ['percent' => ($row['memTotal'] ?? 0) ? round($row['memUsed'] / $row['memTotal'] * 100, 1) : null, 'kind' => 'memory'])@include('livewire.fleet._usage-cell', ['percent' => $row['diskPercent'], 'kind' => 'disk']){{ $row['load1'] === null ? '-' : number_format($row['load1'], 2) }}{{ $row['netRx'] === null ? '-' : formatBytes($row['netRx'] + $row['netTx']).'/s' }}{{ $row['containers'] ?? '-' }}
    +
    +
    + Server + CPU + Memory + Disk + Load + Network + Containers +
    + @foreach (collect($serverRows)->sortByDesc(fn ($r) => $r['cpu'] ?? -1) as $row) +
    +
    + {{ $row['name'] }} + @unless ($row['online']) + Offline + @endunless +
    +
    @include('livewire.fleet._usage-cell', ['percent' => $row['cpu'], 'kind' => 'cpu'])
    +
    @include('livewire.fleet._usage-cell', ['percent' => ($row['memTotal'] ?? 0) ? round($row['memUsed'] / $row['memTotal'] * 100, 1) : null, 'kind' => 'memory'])
    +
    @include('livewire.fleet._usage-cell', ['percent' => $row['diskPercent'], 'kind' => 'disk'])
    +
    {{ $row['load1'] === null ? '-' : number_format($row['load1'], 2) }}
    +
    {{ $row['netRx'] === null ? '-' : formatBytes($row['netRx'] + $row['netTx']).'/s' }}
    +
    {{ $row['containers'] ?? '-' }}
    +
    + @endforeach +
    {{-- Hottest containers --}} @@ -118,32 +124,42 @@
    @foreach (['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'] as $m => $mlabel) + class="{{ $containerMetric === $m ? 'bg-[var(--coollabs-base)] text-black shadow-sm dark:text-fg' : 'text-neutral-500 hover:text-black dark:hover:text-fg' }} rounded px-2.5 py-1 text-[12px] font-medium transition-colors">{{ $mlabel }} @endforeach
    @if (empty($topContainers)) - +
    + +
    @else - - - - @foreach ($topContainers as $c) - - - - - - @endforeach - -
    ContainerServer{{ ucfirst($containerMetric) }}
    @if ($c['link']){{ $c['name'] }}@else{{ $c['name'] }}@endif{{ $c['server'] }} - @switch($containerMetric) - @case('memory'){{ formatBytes($c['memUsed'] ?? 0) }}@break - @case('disk'){{ formatBytes($c['diskBytes'] ?? 0) }}@break - @case('network'){{ formatBytes($c['net'] ?? 0) }}/s@break - @default{{ round($c['cpu'] ?? 0) }}% - @endswitch -
    +
    +
    + Container + Server + {{ ucfirst($containerMetric) }} +
    + @foreach ($topContainers as $c) +
    +
    + @if ($c['link']) + {{ $c['name'] }} + @else + {{ $c['name'] }} + @endif +
    +
    {{ $c['server'] }}
    +
    + @switch($containerMetric) + @case('memory'){{ formatBytes($c['memUsed'] ?? 0) }}@break + @case('disk'){{ formatBytes($c['diskBytes'] ?? 0) }}@break + @case('network'){{ formatBytes($c['net'] ?? 0) }}/s@break + @default{{ round($c['cpu'] ?? 0) }}% + @endswitch +
    +
    + @endforeach +
    @endif @endif @@ -156,7 +172,7 @@ const axisColor = (typeof textColor !== 'undefined') ? textColor : 'rgba(128,128,128,0.7)'; const fmtPercent = v => `${Number(Number(v).toFixed(1))}%`; - const fmtNumber = v => Number(v).toLocaleString(); + const fmtNumber = v => Number(Number(v).toFixed(2)).toLocaleString(); const fmtBytes = v => { const units = ['B', 'KB', 'MB', 'GB', 'TB']; let amount = Number(v) || 0; @@ -166,34 +182,37 @@ }; const fmtRate = v => `${fmtBytes(v)}/s`; - const baseOptions = (colors, formatter) => ({ - chart: { height: 240, type: 'area', toolbar: { show: false }, zoom: { enabled: false }, background: 'transparent' }, - series: [], + const baseOptions = (series, colors, formatter) => ({ + chart: { height: 240, type: 'area', toolbar: { show: false }, zoom: { enabled: false }, background: 'transparent', animations: { enabled: true } }, + series, colors, stroke: { curve: 'smooth', width: 2 }, fill: { type: 'gradient', gradient: { opacityFrom: 0.28, opacityTo: 0.02, stops: [0, 90, 100] } }, dataLabels: { enabled: false }, grid: { borderColor: 'rgba(128, 128, 128, 0.14)', strokeDashArray: 4 }, - legend: { show: false }, + legend: { show: series.length > 1, labels: { colors: axisColor } }, xaxis: { type: 'datetime', labels: { datetimeUTC: true, style: { colors: axisColor } } }, yaxis: { min: 0, max: max => max > 0 ? max * 1.2 : 1, forceNiceScale: true, tickAmount: 4, labels: { style: { colors: axisColor }, formatter } }, noData: { text: 'No data for this range', style: { color: axisColor } }, tooltip: { x: { format: 'yyyy-MM-dd HH:mm' } }, }); - const specs = { - cpu: { colors: ['#3b82f6'], formatter: fmtPercent }, - memory: { colors: ['#8b5cf6'], formatter: fmtBytes }, - network: { colors: ['#10b981', '#f59e0b'], formatter: fmtRate }, - load: { colors: ['#14b8a6'], formatter: fmtNumber }, - disk: { colors: ['#ef4444'], formatter: fmtPercent }, - }; + const initial = @js($chartData); + + const build = data => ({ + cpu: { series: [{ name: 'CPU %', data: data.cpu || [] }], colors: ['#3b82f6'], formatter: fmtPercent }, + memory: { series: [{ name: 'Memory', data: data.memory || [] }], colors: ['#8b5cf6'], formatter: fmtBytes }, + network: { series: [{ name: 'RX', data: data.networkRx || [] }, { name: 'TX', data: data.networkTx || [] }], colors: ['#10b981', '#f59e0b'], formatter: fmtRate }, + load: { series: [{ name: 'Load', data: data.load || [] }], colors: ['#14b8a6'], formatter: fmtNumber }, + disk: { series: [{ name: 'Disk %', data: data.disk || [] }], colors: ['#ef4444'], formatter: fmtPercent }, + }); const charts = {}; + const specs = build(initial || {}); for (const [key, spec] of Object.entries(specs)) { const el = document.getElementById(`fleet-chart-${key}`); if (! el) { continue; } - const chart = new ApexCharts(el, baseOptions(spec.colors, spec.formatter)); + const chart = new ApexCharts(el, baseOptions(spec.series, spec.colors, spec.formatter)); chart.render(); charts[key] = chart; } @@ -202,15 +221,10 @@ if (typeof checkTheme === 'function') { checkTheme(); } const data = Array.isArray(payload) ? payload[0] : payload; if (! data) { return; } - - charts.cpu?.updateSeries([{ name: 'CPU', data: data.cpu || [] }]); - charts.memory?.updateSeries([{ name: 'Memory', data: data.memory || [] }]); - charts.disk?.updateSeries([{ name: 'Disk', data: data.disk || [] }]); - charts.load?.updateSeries([{ name: 'Load', data: data.load || [] }]); - charts.network?.updateSeries([ - { name: 'RX', data: data.networkRx || [] }, - { name: 'TX', data: data.networkTx || [] }, - ]); + const next = build(data); + for (const [key, chart] of Object.entries(charts)) { + chart.updateSeries(next[key].series); + } }); })(); diff --git a/tests/Feature/Livewire/Fleet/MetricsDataTest.php b/tests/Feature/Livewire/Fleet/MetricsDataTest.php index ee10acade2..789694fb12 100644 --- a/tests/Feature/Livewire/Fleet/MetricsDataTest.php +++ b/tests/Feature/Livewire/Fleet/MetricsDataTest.php @@ -130,7 +130,7 @@ it('shows the KPI tiles, a stale badge for offline servers, and the container to ->assertSee('web-1') ->assertSee('web-2') ->assertSee('Fleet CPU') - ->assertSee('needs attention') + ->assertSee('need attention') ->assertSee('Offline'); }); From f3fdee90da04454cb3c9ec9d886c4806359793ef Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:43:37 +0000 Subject: [PATCH 12/19] feat(metrics): add disk/network/load charts to the per-server metrics page --- app/Livewire/Server/Charts.php | 22 ++++++ app/Traits/HasMetrics.php | 70 ++++++++++++++++++- .../views/livewire/server/charts.blade.php | 57 ++++++++++++++- tests/Unit/Metrics/HasMetricsMappingTest.php | 61 ++++++++++++++++ 4 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Metrics/HasMetricsMappingTest.php diff --git a/app/Livewire/Server/Charts.php b/app/Livewire/Server/Charts.php index 567034c801..654f0b9686 100644 --- a/app/Livewire/Server/Charts.php +++ b/app/Livewire/Server/Charts.php @@ -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) { diff --git a/app/Traits/HasMetrics.php b/app/Traits/HasMetrics.php index 712f09a48b..0cba5af8ab 100644 --- a/app/Traits/HasMetrics.php +++ b/app/Traits/HasMetrics.php @@ -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>|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>|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(); diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index 442fc1979f..3592a1528d 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -69,6 +69,21 @@
    + +
    +
    + + +
    +
    + + +
    +
    + @script diff --git a/tests/Unit/Metrics/HasMetricsMappingTest.php b/tests/Unit/Metrics/HasMetricsMappingTest.php new file mode 100644 index 0000000000..aff3dd557c --- /dev/null +++ b/tests/Unit/Metrics/HasMetricsMappingTest.php @@ -0,0 +1,61 @@ + 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(); +}); From 2aa656becef918c55a041e29d66dfdda971a1745 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:49:20 +0000 Subject: [PATCH 13/19] refactor(metrics): mirror analytics header/overview card, declutter KPI tiles --- app/Livewire/Fleet/Metrics.php | 20 -- .../views/livewire/fleet/metrics.blade.php | 172 ++++++++++-------- 2 files changed, 101 insertions(+), 91 deletions(-) diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index e29f8314ae..0d44d1d470 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -35,8 +35,6 @@ class Metrics extends Component #[Url(as: 'cmetric')] public string $containerMetric = 'cpu'; - public bool $live = false; - /** @var array */ public array $kpis = []; @@ -49,8 +47,6 @@ class Metrics extends Component /** @var array Trend series for the charts, also embedded for first paint. */ public array $chartData = []; - public ?string $lastUpdatedAt = null; - /** @var array */ protected array $containerMetaCache = []; @@ -91,21 +87,6 @@ class Metrics extends Component $this->loadData(); } - public function isLivePollable(): bool - { - return $this->live && $this->range === '24h'; - } - - /** - * Re-pull within the 60s cache TTL. The TTL bounds staleness, so a refresh is a - * cheap cache-hit reload rather than a global cache flush (matches the Analytics - * live toggle, which also relies on the TTL). - */ - public function refresh(): void - { - $this->loadData(); - } - /** * @return Collection */ @@ -168,7 +149,6 @@ class Metrics extends Component $this->kpis = FleetMetricsAggregator::fleetKpis($rows); $this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric); $this->chartData = $this->buildChartPayload($series); - $this->lastUpdatedAt = now()->toIso8601String(); $this->dispatch("refreshChartData-{$this->chartId}", $this->chartData); } diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index 514ff66d4e..db28a47285 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -1,79 +1,104 @@ -
    - {{-- Header + controls --}} -
    -
    -

    Metrics

    -

    Resource usage across your servers.

    + '', 'label' => 'All servers']], + collect($serverOptions)->map(fn ($name, $uuid) => ['value' => $uuid, 'label' => $name])->values()->all(), +); +?> +
    + + Metrics | Coolify + + + {{-- Header --}} +
    +
    +

    Metrics

    +

    + Resource usage across every server, reported by Sentinel. +

    + @if ($servers->isNotEmpty()) -
    - @if ($lastUpdatedAt) - - @endif - -
    - @foreach (['24h', '7d', '30d'] as $r) - - @endforeach +
    +
    + +
    + +
    +
    + @foreach (['24h' => '24 hours', '7d' => '7 days', '30d' => '30 days'] as $value => $label) + + @endforeach +
    -
    @endif
    @if ($servers->isEmpty()) - - - - + + + + View servers + + @else - {{-- KPI tiles --}} -
    -
    - Servers online - {{ $kpis['serversOnline'] ?? 0 }}/{{ $kpis['serversTotal'] ?? 0 }} - @if (($kpis['needsAttention'] ?? 0) > 0) - {{ $kpis['needsAttention'] }} need attention - @else - All healthy - @endif + {{-- Overview KPIs --}} + +
    +
    + Servers online + {{ $kpis['serversOnline'] ?? 0 }}/{{ $kpis['serversTotal'] ?? 0 }} + @if (($kpis['needsAttention'] ?? 0) > 0) + {{ $kpis['needsAttention'] }} need attention + @endif +
    +
    + + Fleet CPU + @if ($kpis['cpuApproximate'] ?? false) + ~ + @endif + + {{ round($kpis['cpuAvg'] ?? 0) }}% + @if ($kpis['cpuBusiest'] ?? null) + Busiest {{ $kpis['cpuBusiest']['name'] }} {{ round($kpis['cpuBusiest']['percent']) }}% + @endif +
    +
    + Fleet memory + {{ round($kpis['memPercent'] ?? 0) }}% + {{ formatBytes($kpis['memUsed'] ?? 0) }} / {{ formatBytes($kpis['memTotal'] ?? 0) }} +
    +
    + Fleet disk + {{ round($kpis['diskPercent'] ?? 0) }}% + {{ formatBytes($kpis['diskUsed'] ?? 0) }} / {{ formatBytes($kpis['diskTotal'] ?? 0) }} +
    +
    + Fleet network + {{ formatBytes(($kpis['netRx'] ?? 0) + ($kpis['netTx'] ?? 0)) }}/s + ↓{{ formatBytes($kpis['netRx'] ?? 0) }} ↑{{ formatBytes($kpis['netTx'] ?? 0) }} +
    +
    + Containers + {{ compactNumber($kpis['containers'] ?? 0) }} +
    -
    - Fleet CPU @if ($kpis['cpuApproximate'] ?? false)~@endif - {{ round($kpis['cpuAvg'] ?? 0) }}% - @if ($kpis['cpuBusiest'] ?? null)Busiest: {{ $kpis['cpuBusiest']['name'] }} {{ round($kpis['cpuBusiest']['percent']) }}%@endif -
    -
    - Fleet memory - {{ round($kpis['memPercent'] ?? 0) }}% - {{ formatBytes($kpis['memUsed'] ?? 0) }} / {{ formatBytes($kpis['memTotal'] ?? 0) }} -
    -
    - Fleet disk - {{ round($kpis['diskPercent'] ?? 0) }}% - {{ formatBytes($kpis['diskUsed'] ?? 0) }} / {{ formatBytes($kpis['diskTotal'] ?? 0) }} -
    -
    - Fleet network - {{ formatBytes(($kpis['netRx'] ?? 0) + ($kpis['netTx'] ?? 0)) }}/s - rx {{ formatBytes($kpis['netRx'] ?? 0) }}/s · tx {{ formatBytes($kpis['netTx'] ?? 0) }}/s -
    -
    - Containers - {{ compactNumber($kpis['containers'] ?? 0) }} - running -
    -
    + {{-- Trend charts --}}
    @@ -87,7 +112,8 @@
    {{-- Servers table --}} - +
    Server @@ -119,25 +145,29 @@ {{-- Hottest containers --}} - + -
    +
    @foreach (['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'] as $m => $mlabel) + @class([$tabButtonBase, $containerMetric === $m ? $tabButtonActive : $tabButtonInactive])> + {{ $mlabel }} + @endforeach
    @if (empty($topContainers))
    - +
    @else
    Container Server - {{ ucfirst($containerMetric) }} + {{ ['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'][$containerMetric] }}
    @foreach ($topContainers as $c)
    From 6b0c97211a57aafba4e98fa65b4113ab59a94210 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 09:18:05 +0000 Subject: [PATCH 14/19] fix(metrics): downsample history, re-rank containers without refetch, add live refresh --- app/Livewire/Fleet/Metrics.php | 14 +++++++++- app/Services/SentinelMetricsClient.php | 13 +++++++++- .../views/livewire/fleet/metrics.blade.php | 26 +++++++++++++++++++ .../Livewire/Fleet/MetricsDataTest.php | 19 ++++++++++++++ .../Services/SentinelMetricsClientTest.php | 12 +++++++++ 5 files changed, 82 insertions(+), 2 deletions(-) diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index 0d44d1d470..679fdf5593 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -35,12 +35,17 @@ class Metrics extends Component #[Url(as: 'cmetric')] public string $containerMetric = 'cpu'; + public bool $live = false; + /** @var array */ public array $kpis = []; /** @var array> */ public array $serverRows = []; + /** @var array> All fetched containers, kept so the metric toggle re-ranks without re-fetching. */ + public array $containersRaw = []; + /** @var array> */ public array $topContainers = []; @@ -78,7 +83,8 @@ class Metrics extends Component public function updatedContainerMetric(): void { - $this->loadData(); + // Re-rank the already-fetched containers; no need to re-hit Sentinel. + $this->topContainers = FleetMetricsAggregator::rankContainers($this->containersRaw, $this->containerMetric); } public function setRange(string $range): void @@ -87,6 +93,11 @@ class Metrics extends Component $this->loadData(); } + public function isLivePollable(): bool + { + return $this->live && $this->range === '24h'; + } + /** * @return Collection */ @@ -147,6 +158,7 @@ class Metrics extends Component $this->serverRows = $rows; $this->kpis = FleetMetricsAggregator::fleetKpis($rows); + $this->containersRaw = $containers; $this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric); $this->chartData = $this->buildChartPayload($series); diff --git a/app/Services/SentinelMetricsClient.php b/app/Services/SentinelMetricsClient.php index 5f27f1e564..820fd9c0f8 100644 --- a/app/Services/SentinelMetricsClient.php +++ b/app/Services/SentinelMetricsClient.php @@ -9,6 +9,9 @@ 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) {} public static function rangeFrom(string $range): string @@ -126,10 +129,18 @@ class SentinelMetricsClient return []; } - return array_values(array_map( + $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); + } + + return $series; } /** diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index db28a47285..07bd0d9e0d 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -30,6 +30,28 @@ $serverListboxOptions = array_merge(
    + @if ($range === '24h') +
    + +
    + @endif
    @foreach (['24h' => '24 hours', '7d' => '7 days', '30d' => '30 days'] as $value => $label) - @endforeach +
    + +
    + @foreach (['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'] as $m => $mlabel) + + @endforeach +
    @if (empty($topContainers)) @@ -189,23 +194,28 @@ $serverListboxOptions = array_merge( description="Container metrics need a Sentinel build with the bulk endpoints." />
    @else -
    +
    Container Server {{ ['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'][$containerMetric] }}
    @foreach ($topContainers as $c) + @php $meta = $this->containerMeta($c['id']); @endphp
    -
    - @if ($c['link']) - {{ $c['name'] }} +
    + @if ($meta['link']) + {{ $meta['name'] }} @else - {{ $c['name'] }} + {{ $meta['name'] }} + @endif + @if ($meta['image']) + {{ $meta['image'] }} @endif
    -
    {{ $c['server'] }}
    -
    +
    {{ $c['server'] }}
    +
    @switch($containerMetric) @case('memory'){{ formatBytes($c['memUsed'] ?? 0) }}@break @case('disk'){{ formatBytes($c['diskBytes'] ?? 0) }}@break @@ -223,6 +233,9 @@ $serverListboxOptions = array_merge( @script @endscript From 701c81b28f6eeb2a8477ebd94eb80c1aec61f7b0 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 09:30:56 +0000 Subject: [PATCH 16/19] fix(metrics): custom chart tooltip so it has a background (default is stripped) --- .../views/livewire/fleet/metrics.blade.php | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index 0687779d0c..6db9550bc5 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -251,6 +251,25 @@ $serverListboxOptions = array_merge( }; const fmtRate = v => `${fmtBytes(v)}/s`; + const fmtLocalTime = ts => new Date(ts).toLocaleString(undefined, { hour12: false, timeZoneName: 'short' }); + const fmtUtcTime = ts => new Date(ts).toLocaleString(undefined, { hour12: false, timeZone: 'UTC', timeZoneName: 'short' }); + + // The app strips the default ApexCharts tooltip background (utilities.css), so + // every chart must render its own `apexcharts-tooltip-custom` markup. + const customTooltip = formatter => ({ series, dataPointIndex, w }) => { + const timestamp = w.globals.seriesX[0]?.[dataPointIndex]; + let rows = ''; + series.forEach((s, i) => { + const value = s[dataPointIndex]; + if (value === null || value === undefined) { return; } + rows += `
    ${w.globals.seriesNames[i]}: ${formatter(value)}
    `; + }); + return `
    ${rows} +
    Your time: ${fmtLocalTime(timestamp)}
    +
    UTC: ${fmtUtcTime(timestamp)}
    +
    `; + }; + const baseOptions = (series, colors, formatter) => ({ chart: { height: 240, type: 'area', toolbar: { show: false }, zoom: { enabled: false }, background: 'transparent', animations: { enabled: true } }, series, @@ -263,7 +282,7 @@ $serverListboxOptions = array_merge( xaxis: { type: 'datetime', labels: { datetimeUTC: true, style: { colors: axisColor } } }, yaxis: { min: 0, max: max => max > 0 ? max * 1.2 : 1, forceNiceScale: true, tickAmount: 4, labels: { style: { colors: axisColor }, formatter } }, noData: { text: 'No data for this range', style: { color: axisColor } }, - tooltip: { x: { format: 'yyyy-MM-dd HH:mm' } }, + tooltip: { shared: true, intersect: false, marker: { show: false }, custom: customTooltip(formatter) }, }); const initial = @js($chartData); From ab436942c94ad78f5191526e17c1ba4fbfd8936b Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Thu, 17 Sep 2026 06:30:59 +0000 Subject: [PATCH 17/19] feat(metrics): polish fleet + server metrics UI/UX Refine the /metrics fleet page and align the per-server metrics page with it. Fleet overview: - add live sparklines to the CPU/Memory/Disk/Network KPI tiles (shared axis, capped to ~40 points per sparkline) - fill the Servers online + Containers tiles (fleet-health bar, hottest container) so all six tiles read as one system - scope-aware labels: drop the "Fleet" prefix and helper copy when a single server is selected Trend charts: - hero layout: CPU spans full width, the other four form a 2x2 grid - apply the same hero layout + skeleton to the per-server page for consistency Hottest containers table: - add a rank column and a per-row magnitude bar scaled to the busiest container - fix a leaked Blade @break directive that rendered literally and forced horizontal scroll on the Network tab - keep the value column a fixed width so bars align across rows Live refresh: - add analytics-style loading feedback to the range tabs and server filter - stable wire:key on table rows + width transitions on bars so data glides on live refresh instead of snapping Generalize the shared _sparkline partial (optional color/label/format) so it serves non-traffic metrics without changing analytics behaviour. --- .../Concerns/BuildsMetricsChartPayload.php | 124 +++++++++++++- resources/css/app.css | 2 +- .../livewire/fleet/_usage-cell.blade.php | 2 +- .../fleet/metrics-placeholder.blade.php | 2 +- .../views/livewire/fleet/metrics.blade.php | 159 +++++++++++++++--- .../views/livewire/server/charts.blade.php | 58 ++++--- .../livewire/traffic/_sparkline.blade.php | 16 +- .../Fleet/MetricsChartPayloadTest.php | 55 ++++++ .../Livewire/Fleet/MetricsDataTest.php | 27 ++- 9 files changed, 378 insertions(+), 67 deletions(-) diff --git a/app/Livewire/Concerns/BuildsMetricsChartPayload.php b/app/Livewire/Concerns/BuildsMetricsChartPayload.php index c660ed49d3..8ec43ee2b8 100644 --- a/app/Livewire/Concerns/BuildsMetricsChartPayload.php +++ b/app/Livewire/Concerns/BuildsMetricsChartPayload.php @@ -6,20 +6,130 @@ use App\Services\FleetMetricsAggregator; trait BuildsMetricsChartPayload { + /** + * Sparklines read at a glance, not on close inspection, so they carry far fewer + * points than the full trend charts (industry guidance is ~10-50 per spark). All + * KPI-tile sparks share one downsampled axis so their shapes stay comparable. + */ + private const SPARK_MAX_POINTS = 40; + /** * @param array{cpu: array, memory: array, disk: array, load: array, networkRx: array, networkTx: array} $seriesByServer * @return array */ protected function buildChartPayload(array $seriesByServer): array { + $cpu = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['cpu'], 'avg'); + $memory = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['memory'], 'sum'); + $disk = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['disk'], 'avg'); + $load = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['load'], 'avg'); + $networkRx = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkRx'], 'sum'); + $networkTx = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkTx'], 'sum'); + + // Throughput spark = rx + tx per bucket. + $network = $this->combineSeries($networkRx, $networkTx); + $categories = $this->sparkCategories($cpu, $memory, $disk, $network); + + [$sparkCategories, $sparks] = $this->capSparks($categories, [ + 'cpuSpark' => $this->alignSpark($cpu, $categories), + 'memSpark' => $this->alignSpark($memory, $categories), + 'diskSpark' => $this->alignSpark($disk, $categories), + 'netSpark' => $this->alignSpark($network, $categories), + ], self::SPARK_MAX_POINTS); + return [ 'range' => $this->range, - 'cpu' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['cpu'], 'avg'), - 'memory' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['memory'], 'sum'), - 'disk' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['disk'], 'avg'), - 'load' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['load'], 'avg'), - 'networkRx' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkRx'], 'sum'), - 'networkTx' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkTx'], 'sum'), - ]; + 'cpu' => $cpu, + 'memory' => $memory, + 'disk' => $disk, + 'load' => $load, + 'networkRx' => $networkRx, + 'networkTx' => $networkTx, + // KPI-tile sparklines: values-only series on one shared, capped time axis. + 'sparkCategories' => $sparkCategories, + ] + $sparks; + } + + /** + * Thin every spark and the shared axis to at most $max points using the same + * evenly-spaced indices (first and last always kept), so the series stay aligned. + * + * @param array $categories + * @param array> $sparks + * @return array{0: array, 1: array>} + */ + private function capSparks(array $categories, array $sparks, int $max): array + { + $count = count($categories); + if ($count <= $max || $max < 2) { + return [$categories, $sparks]; + } + + $indices = []; + for ($i = 0; $i < $max; $i++) { + $indices[] = (int) round($i * ($count - 1) / ($max - 1)); + } + $indices = array_values(array_unique($indices)); + + $pick = fn (array $values): array => array_values(array_map(fn (int $i) => $values[$i], $indices)); + + return [$pick($categories), array_map($pick, $sparks)]; + } + + /** + * Sum two [ts, value] series bucket-by-bucket into one sorted [ts, value] series. + * + * @param array $a + * @param array $b + * @return array + */ + private function combineSeries(array $a, array $b): array + { + $map = []; + foreach ([$a, $b] as $series) { + foreach ($series as [$ts, $value]) { + $map[$ts] = ($map[$ts] ?? 0.0) + (float) $value; + } + } + ksort($map); + + return array_map(fn ($ts, $value) => [(int) $ts, $value], array_keys($map), array_values($map)); + } + + /** + * Sorted union of bucket timestamps across every series (the shared spark x-axis). + * + * @param array ...$seriesList + * @return array + */ + private function sparkCategories(array ...$seriesList): array + { + $set = []; + foreach ($seriesList as $series) { + foreach ($series as [$ts]) { + $set[(int) $ts] = true; + } + } + $categories = array_keys($set); + sort($categories); + + return $categories; + } + + /** + * Project a [ts, value] series onto the shared category axis, filling gaps with 0. + * + * @param array $series + * @param array $categories + * @return array + */ + private function alignSpark(array $series, array $categories): array + { + $map = []; + foreach ($series as [$ts, $value]) { + $map[(int) $ts] = $value; + } + + return array_map(fn ($ts) => $map[$ts] ?? 0, $categories); } } diff --git a/resources/css/app.css b/resources/css/app.css index a1d5a7fde1..a6b7fa4b17 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -3188,7 +3188,7 @@ input[type="search"]::-webkit-search-results-decoration { } .fleet-containers-table-grid { - grid-template-columns: minmax(10rem, 1.6fr) minmax(8rem, 1fr) 7rem; + grid-template-columns: 2.25rem minmax(9rem, 1.7fr) minmax(6rem, 0.9fr) minmax(8.5rem, 1fr); } .scheduled-executions-table-grid { diff --git a/resources/views/livewire/fleet/_usage-cell.blade.php b/resources/views/livewire/fleet/_usage-cell.blade.php index 55c233996a..4ef75c08be 100644 --- a/resources/views/livewire/fleet/_usage-cell.blade.php +++ b/resources/views/livewire/fleet/_usage-cell.blade.php @@ -7,6 +7,6 @@ {{ $percent === null ? '-' : round($percent).'%' }}
    diff --git a/resources/views/livewire/fleet/metrics-placeholder.blade.php b/resources/views/livewire/fleet/metrics-placeholder.blade.php index f65f4e4319..0ff339a199 100644 --- a/resources/views/livewire/fleet/metrics-placeholder.blade.php +++ b/resources/views/livewire/fleet/metrics-placeholder.blade.php @@ -22,7 +22,7 @@
    @foreach (['CPU', 'Memory used', 'Network throughput', 'Load average', 'Disk usage'] as $label) - + $label === 'CPU'])> @endforeach diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index 6db9550bc5..b5b0856811 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -3,6 +3,9 @@ $tabButtonBase = 'relative inline-flex h-7 items-center justify-center rounded-m $tabButtonActive = 'bg-white text-black shadow-sm ring-1 ring-neutral-200 dark:bg-white/[0.09] dark:text-fg dark:ring-white/[0.08]'; $tabButtonInactive = 'text-neutral-500 hover:text-black dark:text-fg-faint dark:hover:text-fg'; +// Tiles read "Fleet …" across all servers, but just "CPU / Memory / …" when scoped to one. +$scoped = $serverUuid !== ''; + $serverListboxOptions = array_merge( [['value' => '', 'label' => 'All servers']], collect($serverOptions)->map(fn ($name, $uuid) => ['value' => $uuid, 'label' => $name])->values()->all(), @@ -27,6 +30,10 @@ $serverListboxOptions = array_merge(
    +
    @@ -57,7 +64,8 @@ $serverListboxOptions = array_merge( @endforeach
    @@ -83,53 +91,128 @@ $serverListboxOptions = array_merge( @else {{-- Overview KPIs --}} + helper="{{ $scoped ? 'Current resource usage for the selected server.' : 'Current resource usage across the selected servers.' }}">
    Servers online {{ $kpis['serversOnline'] ?? 0 }}/{{ $kpis['serversTotal'] ?? 0 }} - @if (($kpis['needsAttention'] ?? 0) > 0) - {{ $kpis['needsAttention'] }} need attention - @endif + @php + $sTotal = max(1, (int) ($kpis['serversTotal'] ?? 0)); + $sOnline = (int) ($kpis['serversOnline'] ?? 0); + $sAttention = min($sOnline, (int) ($kpis['needsAttention'] ?? 0)); + $sHealthy = max(0, $sOnline - $sAttention); + $sOffline = max(0, $sTotal - $sOnline); + @endphp +
    +
    +
    + @if ($sHealthy > 0)
    @endif + @if ($sAttention > 0)
    @endif + @if ($sOffline > 0)
    @endif +
    +
    + {{ $sHealthy }} healthy + @if ($sAttention > 0){{ $sAttention }} attention@endif + @if ($sOffline > 0){{ $sOffline }} offline@endif +
    +
    +
    - Fleet CPU + {{ $scoped ? 'CPU' : 'Fleet CPU' }} @if ($kpis['cpuApproximate'] ?? false) ~ @endif {{ round($kpis['cpuAvg'] ?? 0) }}% - @if ($kpis['cpuBusiest'] ?? null) - Busiest {{ $kpis['cpuBusiest']['name'] }} {{ round($kpis['cpuBusiest']['percent']) }}% - @endif +
    + @include('livewire.traffic._sparkline', [ + 'id' => $chartId.'-spark-cpu', + 'initial' => $chartData['cpuSpark'] ?? [], + 'color' => '#3b82f6', + 'event' => 'refreshChartData-'.$chartId, + 'key' => 'cpuSpark', + 'label' => $scoped ? 'CPU' : 'Fleet CPU', + 'format' => 'percent', + ]) +
    - Fleet memory + {{ $scoped ? 'Memory' : 'Fleet memory' }} {{ round($kpis['memPercent'] ?? 0) }}% - {{ formatBytes($kpis['memUsed'] ?? 0) }} / {{ formatBytes($kpis['memTotal'] ?? 0) }} +
    + @include('livewire.traffic._sparkline', [ + 'id' => $chartId.'-spark-memory', + 'initial' => $chartData['memSpark'] ?? [], + 'color' => '#8b5cf6', + 'event' => 'refreshChartData-'.$chartId, + 'key' => 'memSpark', + 'label' => 'Memory used', + 'format' => 'bytes', + ]) +
    - Fleet disk + {{ $scoped ? 'Disk' : 'Fleet disk' }} {{ round($kpis['diskPercent'] ?? 0) }}% - {{ formatBytes($kpis['diskUsed'] ?? 0) }} / {{ formatBytes($kpis['diskTotal'] ?? 0) }} +
    + @include('livewire.traffic._sparkline', [ + 'id' => $chartId.'-spark-disk', + 'initial' => $chartData['diskSpark'] ?? [], + 'color' => '#ef4444', + 'event' => 'refreshChartData-'.$chartId, + 'key' => 'diskSpark', + 'label' => $scoped ? 'Disk' : 'Fleet disk', + 'format' => 'percent', + ]) +
    - Fleet network + {{ $scoped ? 'Network' : 'Fleet network' }} {{ formatBytes(($kpis['netRx'] ?? 0) + ($kpis['netTx'] ?? 0)) }}/s - ↓{{ formatBytes($kpis['netRx'] ?? 0) }} ↑{{ formatBytes($kpis['netTx'] ?? 0) }} +
    + @include('livewire.traffic._sparkline', [ + 'id' => $chartId.'-spark-network', + 'initial' => $chartData['netSpark'] ?? [], + 'color' => '#10b981', + 'event' => 'refreshChartData-'.$chartId, + 'key' => 'netSpark', + 'label' => 'Network', + 'format' => 'bytes', + ]) +
    Containers {{ compactNumber($kpis['containers'] ?? 0) }} +
    +
    + @if (! empty($topContainers)) + @php + $hottest = $this->containerMeta($topContainers[0]['id']); + $hottestValue = match ($containerMetric) { + 'memory' => formatBytes($topContainers[0]['memUsed'] ?? 0), + 'disk' => formatBytes($topContainers[0]['diskBytes'] ?? 0), + 'network' => formatBytes($topContainers[0]['net'] ?? 0).'/s', + default => round($topContainers[0]['cpu'] ?? 0).'%', + }; + @endphp + {{ $hottest['name'] }} + hottest, {{ $hottestValue }} + @endif +
    +
    {{-- Trend charts --}} + {{-- CPU is the primary triage signal, so it spans full width; the other four + fleet series fall into a clean 2x2 below (no orphaned cell). --}}
    @foreach (['cpu' => 'CPU', 'memory' => 'Memory used', 'network' => 'Network throughput', 'load' => 'Load average', 'disk' => 'Disk usage'] as $key => $label) - + $key === 'cpu'])>
    @@ -151,7 +234,8 @@ $serverListboxOptions = array_merge( Containers
    @foreach (collect($serverRows)->sortByDesc(fn ($r) => $r['cpu'] ?? -1) as $row) -
    +
    {{ $row['name'] }} @@ -172,7 +256,7 @@ $serverListboxOptions = array_merge( {{-- Hottest containers --}} + helper="{{ $scoped ? 'Busiest containers on this server, ranked by the selected metric.' : 'Busiest containers across the fleet, ranked by the selected metric.' }}">
    @else + @php + $metricOf = fn ($c) => match ($containerMetric) { + 'memory' => $c['memUsed'] ?? 0, + 'disk' => $c['diskBytes'] ?? 0, + 'network' => $c['net'] ?? 0, + default => $c['cpu'] ?? 0, + }; + // Bars are scaled to the leader so the row length reads as "share of the hottest". + $maxMetric = max(1, collect($topContainers)->map($metricOf)->max()); + @endphp
    + # Container Server {{ ['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'][$containerMetric] }}
    @foreach ($topContainers as $c) - @php $meta = $this->containerMeta($c['id']); @endphp -
    + @php + $meta = $this->containerMeta($c['id']); + $pct = min(100, round($metricOf($c) / $maxMetric * 100, 1)); + $display = match ($containerMetric) { + 'memory' => formatBytes($c['memUsed'] ?? 0), + 'disk' => formatBytes($c['diskBytes'] ?? 0), + 'network' => formatBytes($c['net'] ?? 0).'/s', + default => round($c['cpu'] ?? 0).'%', + }; + @endphp +
    +
    {{ $loop->iteration }}
    @if ($meta['link']) {{ $meta['name'] }} @@ -215,13 +321,12 @@ $serverListboxOptions = array_merge( @endif
    {{ $c['server'] }}
    -
    - @switch($containerMetric) - @case('memory'){{ formatBytes($c['memUsed'] ?? 0) }}@break - @case('disk'){{ formatBytes($c['diskBytes'] ?? 0) }}@break - @case('network'){{ formatBytes($c['net'] ?? 0) }}/s@break - @default{{ round($c['cpu'] ?? 0) }}% - @endswitch +
    + + {{ $display }}
    @endforeach diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index 3592a1528d..edf970ffae 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -59,30 +59,34 @@ - -
    -
    + {{-- CPU is the primary signal, so it spans full width; the other four + charts fall into a 2x2 below (mirrors the fleet /metrics layout). --}} +
    + +
    +
    - -
    -
    + +
    +
    - -
    -
    + +
    +
    - -
    -
    + +
    +
    - -
    -
    + +
    +
    +
    @script