From a12f62ed4fbc40b5276dfe96e44e73137d07bd5f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:58:59 +0100 Subject: [PATCH] fix(dashboard): bound recent deployments query per application Co-Authored-By: Claude Opus 5.5 --- app/Livewire/Dashboard/ActiveDeployments.php | 63 ++++++++--- app/Models/ApplicationDeploymentQueue.php | 19 +++- ...index_for_dashboard_recent_deployments.php | 41 +++++++ tests/Feature/DashboardDeploymentsTest.php | 107 ++++++++++++++++++ 4 files changed, 213 insertions(+), 17 deletions(-) create mode 100644 database/migrations/2026_10_28_224423_add_index_for_dashboard_recent_deployments.php diff --git a/app/Livewire/Dashboard/ActiveDeployments.php b/app/Livewire/Dashboard/ActiveDeployments.php index 00601df807..b8d706bffe 100644 --- a/app/Livewire/Dashboard/ActiveDeployments.php +++ b/app/Livewire/Dashboard/ActiveDeployments.php @@ -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|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 + */ + private function teamApplicationIds(): Collection + { + return $this->teamApplicationIds ??= ApplicationDeploymentQueue::applicationIdsOwnedByTeam(currentTeam()->id); } public function render() diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 3af70a5610..81019a288a 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -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 + */ + 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 diff --git a/database/migrations/2026_10_28_224423_add_index_for_dashboard_recent_deployments.php b/database/migrations/2026_10_28_224423_add_index_for_dashboard_recent_deployments.php new file mode 100644 index 0000000000..982cb2f60b --- /dev/null +++ b/database/migrations/2026_10_28_224423_add_index_for_dashboard_recent_deployments.php @@ -0,0 +1,41 @@ +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'); + }); + } + } +}; diff --git a/tests/Feature/DashboardDeploymentsTest.php b/tests/Feature/DashboardDeploymentsTest.php index bec45a4985..41cd6f8da6 100644 --- a/tests/Feature/DashboardDeploymentsTest.php +++ b/tests/Feature/DashboardDeploymentsTest.php @@ -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'); + } +});