From fb021c3d9d80d5ace1cd555dabfc686861e12242 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 07:58:55 +0000 Subject: [PATCH] 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); +});