mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(dashboard): bound recent deployments query per application
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3d956c110c
commit
a12f62ed4f
@@ -6,6 +6,7 @@ use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -28,6 +29,18 @@ class ActiveDeployments extends Component
|
||||
'finished_at',
|
||||
];
|
||||
|
||||
private const ACTIVE_STATUSES = [
|
||||
ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
ApplicationDeploymentStatus::QUEUED->value,
|
||||
];
|
||||
|
||||
/**
|
||||
* Not dehydrated, so it is loaded once per request and shared by both lists.
|
||||
*
|
||||
* @var Collection<int, string>|null
|
||||
*/
|
||||
private ?Collection $teamApplicationIds = null;
|
||||
|
||||
/**
|
||||
* Polling target. The lists are loaded again on every render.
|
||||
*/
|
||||
@@ -37,10 +50,8 @@ class ActiveDeployments extends Component
|
||||
public function activeDeployments(): Collection
|
||||
{
|
||||
return $this->deploymentsQuery()
|
||||
->whereIn('status', [
|
||||
ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
ApplicationDeploymentStatus::QUEUED->value,
|
||||
])
|
||||
->whereIn('application_id', $this->teamApplicationIds())
|
||||
->whereIn('status', self::ACTIVE_STATUSES)
|
||||
->orderBy('status')
|
||||
->orderBy('id')
|
||||
->limit(5)
|
||||
@@ -50,21 +61,47 @@ class ActiveDeployments extends Component
|
||||
#[Computed]
|
||||
public function recentDeployments(): Collection
|
||||
{
|
||||
return $this->deploymentsQuery()
|
||||
->whereNotIn('status', [
|
||||
ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
ApplicationDeploymentStatus::QUEUED->value,
|
||||
])
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
return $this->deploymentsQuery()
|
||||
->whereIn('application_id', $this->teamApplicationIds())
|
||||
->whereNotIn('status', self::ACTIVE_STATUSES)
|
||||
->orderByDesc('id')
|
||||
->limit(5)
|
||||
->get(self::COLUMNS);
|
||||
}
|
||||
|
||||
// With "application_id IN (...) ORDER BY id DESC" PostgreSQL scans the primary key backwards and can
|
||||
// read most of the table for a team without recent deployments. Take the five newest of each
|
||||
// application from idx_deployment_queues_app_id instead; the five newest of the team are among them.
|
||||
$newestPerApplication = ApplicationDeploymentQueue::query()
|
||||
->select(self::COLUMNS)
|
||||
->whereColumn('application_id', 'team_applications.id')
|
||||
->whereNotIn('status', self::ACTIVE_STATUSES)
|
||||
->orderByDesc('id')
|
||||
->limit(5);
|
||||
|
||||
return $this->deploymentsQuery()
|
||||
->fromRaw('unnest(?::varchar[]) as team_applications(id)', ['{'.$this->teamApplicationIds()->implode(',').'}'])
|
||||
->joinLateral($newestPerApplication, 'application_deployment_queues')
|
||||
->orderByDesc('application_deployment_queues.id')
|
||||
->limit(5)
|
||||
->get(self::COLUMNS);
|
||||
->get(array_map(fn (string $column) => "application_deployment_queues.{$column}", self::COLUMNS));
|
||||
}
|
||||
|
||||
private function deploymentsQuery(): Builder
|
||||
{
|
||||
return ApplicationDeploymentQueue::query()
|
||||
->with(['application.environment.project'])
|
||||
->ownedByTeam(currentTeam()->id);
|
||||
return ApplicationDeploymentQueue::query()->with([
|
||||
'application' => fn ($query) => $query->withoutGlobalScope('withRelations')->select(['id', 'environment_id']),
|
||||
'application.environment.project',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, string>
|
||||
*/
|
||||
private function teamApplicationIds(): Collection
|
||||
{
|
||||
return $this->teamApplicationIds ??= ApplicationDeploymentQueue::applicationIdsOwnedByTeam(currentTeam()->id);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use OpenApi\Attributes as OA;
|
||||
@@ -166,13 +167,23 @@ class ApplicationDeploymentQueue extends Model
|
||||
*/
|
||||
public function scopeOwnedByTeam(Builder $query, int $teamId): Builder
|
||||
{
|
||||
// application_id is varchar; whereHas joins to applications.id (bigint) and breaks on PostgreSQL.
|
||||
$applicationIds = Application::withTrashed()
|
||||
return $query->whereIn('application_id', self::applicationIdsOwnedByTeam($teamId));
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs of the team's applications, deleted ones included, as strings to match application_id.
|
||||
*
|
||||
* application_id is varchar; whereHas joins to applications.id (bigint) and breaks on PostgreSQL.
|
||||
*
|
||||
* @return Collection<int, string>
|
||||
*/
|
||||
public static function applicationIdsOwnedByTeam(int $teamId): Collection
|
||||
{
|
||||
return Application::withTrashed()
|
||||
->withoutGlobalScope('withRelations')
|
||||
->whereRelation('environment.project.team', 'id', $teamId)
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (string) $id);
|
||||
|
||||
return $query->whereIn('application_id', $applicationIds);
|
||||
}
|
||||
|
||||
public function server(): Attribute
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public $withinTransaction = false;
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::getDriverName() === 'pgsql') {
|
||||
DB::statement('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_deployment_queues_app_id ON application_deployment_queues (application_id, id)');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasIndex('application_deployment_queues', 'idx_deployment_queues_app_id')) {
|
||||
Schema::table('application_deployment_queues', function (Blueprint $table) {
|
||||
$table->index(['application_id', 'id'], 'idx_deployment_queues_app_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::getDriverName() === 'pgsql') {
|
||||
DB::statement('DROP INDEX CONCURRENTLY IF EXISTS idx_deployment_queues_app_id');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (Schema::hasIndex('application_deployment_queues', 'idx_deployment_queues_app_id')) {
|
||||
Schema::table('application_deployment_queues', function (Blueprint $table) {
|
||||
$table->dropIndex('idx_deployment_queues_app_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -212,3 +212,110 @@ it('does not load deployment payload columns when the dashboard list polls', fun
|
||||
->not->toContain('configuration_diff');
|
||||
}
|
||||
});
|
||||
|
||||
function createDashboardApplication(array $overrides = []): Application
|
||||
{
|
||||
return Application::factory()->create(array_merge([
|
||||
'environment_id' => test()->environment->id,
|
||||
'destination_id' => test()->destination->id,
|
||||
'destination_type' => test()->destination->getMorphClass(),
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
it('lists the five newest eligible deployments of the team across its applications', function () {
|
||||
$secondApplication = createDashboardApplication();
|
||||
$thirdApplication = createDashboardApplication();
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherApplication = createDashboardApplication([
|
||||
'environment_id' => Environment::factory()->create(['project_id' => $otherProject->id])->id,
|
||||
]);
|
||||
|
||||
$expected = [];
|
||||
foreach (range(1, 4) as $round) {
|
||||
foreach ([$this->application, $secondApplication, $thirdApplication] as $application) {
|
||||
$expected[] = createDashboardDeployment([
|
||||
'application_id' => $application->id,
|
||||
'status' => $round % 2 === 0
|
||||
? ApplicationDeploymentStatus::FAILED->value
|
||||
: ApplicationDeploymentStatus::FINISHED->value,
|
||||
])->id;
|
||||
}
|
||||
}
|
||||
createDashboardDeployment(['application_id' => $otherApplication->id]);
|
||||
createDashboardDeployment(['application_id' => $secondApplication->id, 'status' => ApplicationDeploymentStatus::QUEUED->value]);
|
||||
createDashboardDeployment(['application_id' => $thirdApplication->id, 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value]);
|
||||
|
||||
$component = Livewire::test(ActiveDeployments::class);
|
||||
|
||||
expect($component->get('recentDeployments')->pluck('id')->all())
|
||||
->toBe(array_slice(array_reverse($expected), 0, 5));
|
||||
});
|
||||
|
||||
it('lists fewer than five recent deployments when fewer are eligible', function () {
|
||||
$finished = createDashboardDeployment()->id;
|
||||
$failed = createDashboardDeployment(['status' => ApplicationDeploymentStatus::FAILED->value])->id;
|
||||
createDashboardDeployment(['status' => ApplicationDeploymentStatus::QUEUED->value]);
|
||||
|
||||
expect(Livewire::test(ActiveDeployments::class)->get('recentDeployments')->pluck('id')->all())
|
||||
->toBe([$failed, $finished]);
|
||||
});
|
||||
|
||||
it('keeps deployments of deleted applications and skips deployments without an application', function () {
|
||||
$deletedApplication = createDashboardApplication();
|
||||
$deployment = createDashboardDeployment([
|
||||
'application_id' => $deletedApplication->id,
|
||||
'application_name' => 'Deleted App Deployment',
|
||||
]);
|
||||
$deletedApplication->delete();
|
||||
createDashboardDeployment([
|
||||
'application_id' => '999999',
|
||||
'application_name' => 'Orphan Deployment',
|
||||
]);
|
||||
|
||||
$component = Livewire::test(ActiveDeployments::class);
|
||||
|
||||
expect($component->get('recentDeployments')->pluck('id')->all())->toBe([$deployment->id])
|
||||
->and($component->get('recentDeployments')->first()->application)->toBeNull();
|
||||
$component->assertSee('Deleted App Deployment')->assertDontSee('Orphan Deployment');
|
||||
});
|
||||
|
||||
it('moves a deployment from the active to the recent list when its status changes', function () {
|
||||
$deployment = createDashboardDeployment([
|
||||
'application_name' => 'Status Change App',
|
||||
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
]);
|
||||
|
||||
$component = Livewire::test(ActiveDeployments::class);
|
||||
expect($component->get('activeDeployments')->pluck('id')->all())->toBe([$deployment->id])
|
||||
->and($component->get('recentDeployments'))->toBeEmpty();
|
||||
|
||||
$deployment->setStatus(ApplicationDeploymentStatus::FINISHED->value);
|
||||
$component->call('refreshDeployments');
|
||||
|
||||
expect($component->get('activeDeployments'))->toBeEmpty()
|
||||
->and($component->get('recentDeployments')->pluck('id')->all())->toBe([$deployment->id]);
|
||||
$component->assertDontSee('Running or queued right now')->assertSee('Recent deployments');
|
||||
});
|
||||
|
||||
it('looks up the team applications once per request without loading full applications', function () {
|
||||
createDashboardDeployment();
|
||||
createDashboardDeployment(['status' => ApplicationDeploymentStatus::IN_PROGRESS->value]);
|
||||
|
||||
$applicationQueries = [];
|
||||
DB::listen(function ($query) use (&$applicationQueries) {
|
||||
if (str_contains($query->sql, 'from "applications"')) {
|
||||
$applicationQueries[] = $query->sql;
|
||||
}
|
||||
});
|
||||
|
||||
Livewire::test(ActiveDeployments::class)->assertSee('Dashboard App');
|
||||
|
||||
$teamLookups = array_filter($applicationQueries, fn ($sql) => str_contains($sql, 'exists'));
|
||||
expect($teamLookups)->toHaveCount(1);
|
||||
foreach ($applicationQueries as $sql) {
|
||||
expect($sql)->not->toMatch('/^select\s+("?\w+"?\.)?\*/i')
|
||||
->not->toContain('additional_servers_count')
|
||||
->not->toContain('additional_networks_count');
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user