Files
coolify/tests/Feature/Livewire/Fleet/MetricsDataTest.php
T
Aditya Tripathi faca466cd6 fix(metrics): align fleet series by time bucket, harden fleet page inputs
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
2026-09-18 07:44:22 +00:00

230 lines
8.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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');
});