mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-14 08:42:38 -04:00
Address review findings on the fleet metrics page. Data correctness: - bucket raw Sentinel samples into epoch-aligned windows (5m/30m/2h per range) and average them, replacing per-server LTTB. Every server now reports the same bucket timestamps, so the fleet sums add up point by point instead of zigzagging between servers (and RX+TX really combine) - keep only the root mount in disk history, matching HasMetrics::getDiskMetrics - leave sparkline gaps as null instead of 0, so absent samples read as gaps Robustness: - normalize containerMetric in mount()/updatedContainerMetric(), so /metrics?cmetric=foo no longer throws an undefined array key - include the server uuid in the container row wire:key; system containers share one Sentinel id across servers - round the range start to the minute so the 60s history cache can hit - cap the Sentinel curl with --max-time 10, so one hung request cannot stall the serial per-server loop UI: - pass the spark time axis on first paint, so tooltips no longer show 1970 - show the active series name (RX/TX) in the server network chart tooltip
230 lines
8.3 KiB
PHP
230 lines
8.3 KiB
PHP
<?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 $range): array
|
||
{
|
||
return [];
|
||
}
|
||
|
||
public function networkHistory(string $range): 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('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('healthy')
|
||
->assertSee('attention')
|
||
->assertSee('Offline');
|
||
});
|
||
|
||
it('re-ranks containers on metric switch without re-fetching', function () {
|
||
$s = metricsServer($this->team, $this->privateKey);
|
||
|
||
StubMetricsClient::$summaries = [$s->uuid => onlineSummary()];
|
||
StubMetricsClient::$containers = [$s->uuid => [
|
||
['id' => 'a', 'cpu' => 5.0, 'memUsed' => 900, 'memPercent' => 9.0, 'diskBytes' => 1, 'net' => 0.0],
|
||
['id' => 'b', 'cpu' => 90.0, 'memUsed' => 100, 'memPercent' => 1.0, 'diskBytes' => 9, 'net' => 0.0],
|
||
]];
|
||
|
||
$component = Livewire::test(TestableMetrics::class);
|
||
expect(array_column($component->get('topContainers'), 'id')[0])->toBe('b'); // cpu
|
||
|
||
// Empty the stub so a re-fetch would change nothing/ break; the re-rank must use cached rows.
|
||
StubMetricsClient::$containers = [];
|
||
$component->set('containerMetric', 'memory');
|
||
|
||
expect(array_column($component->get('topContainers'), 'id')[0])->toBe('a'); // memory, still ranked
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
it('drops the "Fleet" tile prefix when scoped to a single server', function () {
|
||
$s = metricsServer($this->team, $this->privateKey);
|
||
StubMetricsClient::$summaries = [$s->uuid => onlineSummary()];
|
||
|
||
Livewire::test(TestableMetrics::class)
|
||
->assertSee('Fleet CPU')
|
||
->set('serverUuid', $s->uuid)
|
||
->assertDontSee('Fleet')
|
||
->assertSee('for the selected server');
|
||
});
|
||
|
||
it('renders container metric values without leaking a blade directive', function () {
|
||
$s = metricsServer($this->team, $this->privateKey);
|
||
StubMetricsClient::$summaries = [$s->uuid => onlineSummary()];
|
||
StubMetricsClient::$containers = [$s->uuid => [
|
||
['id' => 'a', 'cpu' => 5.0, 'memUsed' => 900, 'memPercent' => 9.0, 'diskBytes' => 1, 'net' => 2048.0],
|
||
]];
|
||
|
||
Livewire::test(TestableMetrics::class)
|
||
->set('containerMetric', 'network')
|
||
->assertDontSee('@break')
|
||
->assertSee('/s');
|
||
});
|
||
|
||
it('falls back to cpu for an unknown container metric', function () {
|
||
$s = metricsServer($this->team, $this->privateKey);
|
||
StubMetricsClient::$summaries = [$s->uuid => onlineSummary()];
|
||
StubMetricsClient::$containers = [$s->uuid => [
|
||
['id' => 'a', 'cpu' => 5.0, 'memUsed' => 900, 'memPercent' => 9.0, 'diskBytes' => 1, 'net' => 0.0],
|
||
]];
|
||
|
||
Livewire::withQueryParams(['cmetric' => 'foo'])
|
||
->test(TestableMetrics::class)
|
||
->assertOk()
|
||
->assertSet('containerMetric', 'cpu')
|
||
->set('containerMetric', 'bar')
|
||
->assertSet('containerMetric', 'cpu');
|
||
});
|
||
|
||
it('keys container rows by server so shared container ids do not collide', function () {
|
||
$one = metricsServer($this->team, $this->privateKey);
|
||
$two = metricsServer($this->team, $this->privateKey);
|
||
$proxy = ['id' => 'coolify-proxy', 'cpu' => 1.0, 'memUsed' => 10, 'memPercent' => 1.0, 'diskBytes' => 1, 'net' => 0.0];
|
||
|
||
StubMetricsClient::$summaries = [$one->uuid => onlineSummary(), $two->uuid => onlineSummary()];
|
||
StubMetricsClient::$containers = [$one->uuid => [$proxy], $two->uuid => [$proxy]];
|
||
|
||
Livewire::test(TestableMetrics::class)
|
||
->assertSeeHtml('wire:key="fleet-container-'.$one->uuid.'-coolify-proxy"')
|
||
->assertSeeHtml('wire:key="fleet-container-'.$two->uuid.'-coolify-proxy"');
|
||
});
|
||
|
||
it('passes the spark time axis to the KPI sparklines on first paint', function () {
|
||
$s = metricsServer($this->team, $this->privateKey);
|
||
StubMetricsClient::$summaries = [$s->uuid => onlineSummary()];
|
||
|
||
$component = Livewire::test(TestableMetrics::class);
|
||
$component->set('chartData', ['sparkCategories' => [1700000100000], 'cpuSpark' => [12.5]]);
|
||
|
||
$component->assertSeeHtml('1700000100000');
|
||
});
|