mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-15 09:12:32 -04:00
The analytics page fetched each server's dashboard bundle over SSH one after another, so the skeleton stayed until every server answered. Start all fetches at the same time and remember failures, so the page waits only for the slowest server. Closes #12347
122 lines
4.6 KiB
PHP
122 lines
4.6 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Analytics;
|
|
use App\Livewire\Dashboard\TrafficAnalytics;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Server;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use App\Services\SentinelTrafficClient;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Process\PendingProcess;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
/**
|
|
* A dead server must not stall analytics pages that poll every Sentinel over SSH.
|
|
*/
|
|
beforeEach(function () {
|
|
Server::flushIdentityMap();
|
|
Cache::flush();
|
|
config(['constants.ssh.mux_enabled' => false, 'constants.ssh.retry_base_delay' => 0]);
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->server = Server::factory()->create([
|
|
'team_id' => $this->team->id,
|
|
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
|
|
]);
|
|
$this->server->settings->update(['is_traffic_analytics_enabled' => true]);
|
|
});
|
|
|
|
function markTrafficServerReachable(Server $server): void
|
|
{
|
|
$server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
|
|
$server->refresh();
|
|
}
|
|
|
|
it('does not run a remote command for a server that is not reachable', function () {
|
|
Process::fake(fn () => Process::result(output: '{"requests":1}'));
|
|
$this->server->settings->update(['is_reachable' => false]);
|
|
$client = new SentinelTrafficClient($this->server->fresh());
|
|
[$from, $to] = SentinelTrafficClient::rangeWindow('24h');
|
|
|
|
expect(fn () => $client->overview(null, $from, $to))->toThrow(RuntimeException::class);
|
|
expect($client->prefetchServerWide(null, $from, $to, [], '24h'))->toBe([]);
|
|
|
|
Process::assertNothingRan();
|
|
});
|
|
|
|
it('skips the dashboard fetch of an unreachable server without a remote call', function () {
|
|
Process::fake(fn () => Process::result(output: '{"requests":1}'));
|
|
$user = User::factory()->create();
|
|
$this->team->members()->attach($user->id, ['role' => 'owner']);
|
|
$this->actingAs($user);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
loadLazy(Livewire::test(TrafficAnalytics::class))
|
|
->assertOk()
|
|
->assertSet('overview', null);
|
|
|
|
Process::assertNothingRan();
|
|
});
|
|
|
|
it('tries a failing server once and skips it until the failure window ends', function () {
|
|
markTrafficServerReachable($this->server);
|
|
Process::fake(fn () => Process::result(
|
|
errorOutput: "ssh: connect to host {$this->server->ip} port 22: Connection timed out",
|
|
exitCode: 255,
|
|
));
|
|
$client = new SentinelTrafficClient($this->server);
|
|
|
|
expect(fn () => $client->overview(null, 'a', 'b'))->toThrow(RuntimeException::class);
|
|
Process::assertRanTimes(fn () => true, 1);
|
|
|
|
expect(fn () => $client->overview('app', 'a', 'b'))->toThrow(RuntimeException::class);
|
|
Process::assertRanTimes(fn () => true, 1);
|
|
|
|
$this->travel(SentinelTrafficClient::UNAVAILABLE_TTL + 1)->seconds();
|
|
|
|
expect(fn () => $client->overview('app', 'a', 'b'))->toThrow(RuntimeException::class);
|
|
Process::assertRanTimes(fn () => true, 2);
|
|
});
|
|
|
|
it('fetches a healthy server with a bounded ssh and curl timeout', function () {
|
|
markTrafficServerReachable($this->server);
|
|
Process::fake(fn () => Process::result(output: '{"requests":7}'));
|
|
|
|
$overview = (new SentinelTrafficClient($this->server))->overview(null, 'a', 'b');
|
|
|
|
expect($overview->requests)->toBe(7);
|
|
Process::assertRan(fn (PendingProcess $process) => $process->timeout <= 30
|
|
&& str_contains(remoteProcessScript($process), '--max-time'));
|
|
});
|
|
|
|
it('shows no data without an error when Sentinel has no traffic routes (0.0.x answers 404)', function () {
|
|
markTrafficServerReachable($this->server);
|
|
// Gin's 404 body for every unknown route, including /api/traffic/dashboard.
|
|
Process::fake(fn () => Process::result(output: '404 page not found'));
|
|
$user = User::factory()->create();
|
|
$this->team->members()->attach($user->id, ['role' => 'owner']);
|
|
$this->actingAs($user);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
loadLazy(Livewire::test(Analytics::class))
|
|
->assertOk()
|
|
->assertSet('overview', null)
|
|
->assertSet('topApps', [])
|
|
->assertSet('series', [])
|
|
->assertNotDispatched('error');
|
|
|
|
loadLazy(Livewire::test(TrafficAnalytics::class))
|
|
->assertOk()
|
|
->assertSet('overview', null)
|
|
->assertSet('series', [])
|
|
->assertNotDispatched('error');
|
|
|
|
// The concurrent prefetch remembers the failure, so the per-server pass does not fetch it again.
|
|
Process::assertRanTimes(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), '/api/traffic/dashboard'), 1);
|
|
});
|