feat(metrics): populate fleet KPIs, server rows, and container ranking

This commit is contained in:
Aditya Tripathi
2026-09-16 07:58:55 +00:00
parent ba8ed4e8fb
commit fb021c3d9d
2 changed files with 277 additions and 0 deletions
+149
View File
@@ -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<string, mixed> */
public array $kpis = [];
/** @var array<int, array<string, mixed>> */
public array $serverRows = [];
/** @var array<int, array<string, mixed>> */
public array $topContainers = [];
/** @var array<string, array{name: string, link: ?string}> */
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<int, Server>
*/
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<string, mixed>
*/
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<string, mixed>
*/
protected function chartPayload(): array
{
return ['range' => $this->range];
}
public function placeholder(): View
@@ -0,0 +1,128 @@
<?php
use App\Livewire\Fleet\Metrics;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use App\Services\SentinelMetricsClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
class StubMetricsClient extends SentinelMetricsClient
{
/** @var array<string, ?array> serverUuid => summary */
public static array $summaries = [];
/** @var array<string, 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);
});