From 2616b4bba12657d232d8d61fea09e7928cd01bbd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:44:28 +0100 Subject: [PATCH] fix(deployments): index history ordering and skip payload columns in lists Co-Authored-By: Claude Opus 5.5 --- app/Livewire/Dashboard/ActiveDeployments.php | 69 ++++++----- .../Project/Application/Deployment/Index.php | 106 ++++++++--------- app/Models/Application.php | 3 +- ...dex_for_application_deployment_history.php | 41 +++++++ .../dashboard/active-deployments.blade.php | 8 +- tests/Feature/DashboardDeploymentsTest.php | 22 ++++ .../Feature/DeploymentHistoryPayloadTest.php | 111 ++++++++++++++++++ 7 files changed, 267 insertions(+), 93 deletions(-) create mode 100644 database/migrations/2026_10_28_214500_add_index_for_application_deployment_history.php create mode 100644 tests/Feature/DeploymentHistoryPayloadTest.php diff --git a/app/Livewire/Dashboard/ActiveDeployments.php b/app/Livewire/Dashboard/ActiveDeployments.php index 216c1f0bc4..00601df807 100644 --- a/app/Livewire/Dashboard/ActiveDeployments.php +++ b/app/Livewire/Dashboard/ActiveDeployments.php @@ -4,41 +4,39 @@ namespace App\Livewire\Dashboard; use App\Enums\ApplicationDeploymentStatus; use App\Models\ApplicationDeploymentQueue; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; +use Livewire\Attributes\Computed; use Livewire\Component; +/** + * The lists are computed, not public properties: Livewire would restore them with SELECT * on every poll. + */ class ActiveDeployments extends Component { - public Collection $activeDeployments; + private const COLUMNS = [ + 'id', + 'application_id', + 'application_name', + 'deployment_url', + 'deployment_uuid', + 'pull_request_id', + 'server_name', + 'server_id', + 'status', + 'created_at', + 'finished_at', + ]; - public Collection $recentDeployments; + /** + * Polling target. The lists are loaded again on every render. + */ + public function refreshDeployments(): void {} - public function mount(): void + #[Computed] + public function activeDeployments(): Collection { - $this->refreshDeployments(); - } - - public function refreshDeployments(): void - { - $columns = [ - 'id', - 'application_id', - 'application_name', - 'deployment_url', - 'deployment_uuid', - 'pull_request_id', - 'server_name', - 'server_id', - 'status', - 'created_at', - 'finished_at', - ]; - - $baseQuery = ApplicationDeploymentQueue::query() - ->with(['application.environment.project']) - ->ownedByTeam(currentTeam()->id); - - $this->activeDeployments = (clone $baseQuery) + return $this->deploymentsQuery() ->whereIn('status', [ ApplicationDeploymentStatus::IN_PROGRESS->value, ApplicationDeploymentStatus::QUEUED->value, @@ -46,16 +44,27 @@ class ActiveDeployments extends Component ->orderBy('status') ->orderBy('id') ->limit(5) - ->get($columns); + ->get(self::COLUMNS); + } - $this->recentDeployments = (clone $baseQuery) + #[Computed] + public function recentDeployments(): Collection + { + return $this->deploymentsQuery() ->whereNotIn('status', [ ApplicationDeploymentStatus::IN_PROGRESS->value, ApplicationDeploymentStatus::QUEUED->value, ]) ->orderByDesc('id') ->limit(5) - ->get($columns); + ->get(self::COLUMNS); + } + + private function deploymentsQuery(): Builder + { + return ApplicationDeploymentQueue::query() + ->with(['application.environment.project']) + ->ownedByTeam(currentTeam()->id); } public function render() diff --git a/app/Livewire/Project/Application/Deployment/Index.php b/app/Livewire/Project/Application/Deployment/Index.php index 08194f6bfc..40a9dc8e68 100644 --- a/app/Livewire/Project/Application/Deployment/Index.php +++ b/app/Livewire/Project/Application/Deployment/Index.php @@ -6,7 +6,6 @@ use App\Enums\ApplicationDeploymentStatus; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Traits\ListensToTeamChannel; -use Illuminate\Support\Collection; use Livewire\Attributes\On; use Livewire\Component; @@ -14,9 +13,28 @@ class Index extends Component { use ListensToTeamChannel; - public Application $application; + /** + * The history list never loads logs or configuration payloads. + */ + public const HISTORY_COLUMNS = [ + 'id', + 'application_id', + 'deployment_uuid', + 'pull_request_id', + 'status', + 'commit', + 'commit_message', + 'is_webhook', + 'is_api', + 'rollback', + 'server_id', + 'server_name', + 'created_at', + 'updated_at', + 'finished_at', + ]; - public ?Collection $deployments; + public Application $application; public int $deployments_count = 0; @@ -31,11 +49,8 @@ class Index extends Component $this->defaultTake = max(1, min(100, $this->defaultTake)); $this->skip = 0; - $this->loadDeployments(); } - public bool $showNext = false; - public bool $showPrev = false; public int $currentPage = 1; @@ -107,47 +122,24 @@ class Index extends Component } $this->loadPullRequestOptions(); $this->loadDeploymentFilterOptions(); - ['deployments' => $deployments, 'count' => $count] = $application->deployments( - search: $this->search, - filters: $this->deploymentFilters, - sort: $this->deploymentSort, - take: $this->defaultTake, - pullRequestId: $this->pull_request_id, - ); - $this->deployments = $deployments; - $this->deployments_count = $count; $this->current_url = route('project.application.deployment.index', [ 'project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid, ]); $this->updateCurrentPage(); - $this->showMore(); } - private function showMore() - { - if ($this->deployments->count() !== 0) { - $this->showNext = true; - if ($this->deployments->count() < $this->defaultTake) { - $this->showNext = false; - } - - return; - } - } - - public function reloadDeployments() - { - $this->loadDeployments(); - } + /** + * Polling target. The list is loaded again on every render. + */ + public function reloadDeployments(): void {} public function previousPage(): void { $this->skip = max(0, $this->skip - $this->defaultTake); $this->showPrev = $this->skip > 0; $this->updateCurrentPage(); - $this->loadDeployments(); } public function nextPage(): void @@ -162,27 +154,11 @@ class Index extends Component $this->skip = ($page - 1) * $this->defaultTake; $this->showPrev = $page > 1; $this->updateCurrentPage(); - $this->loadDeployments(); - } - - public function loadDeployments() - { - ['deployments' => $deployments, 'count' => $count] = $this->application->deployments( - skip: $this->skip, - take: $this->defaultTake, - pullRequestId: $this->pull_request_id, - search: $this->search, - filters: $this->deploymentFilters, - sort: $this->deploymentSort, - ); - $this->deployments = $deployments; - $this->deployments_count = $count; - $this->showMore(); } public function updatedSearch(): void { - $this->resetPaginationAndLoad(); + $this->resetPagination(); } public function toggleDeploymentFilter(string $filter): void @@ -204,7 +180,7 @@ class Index extends Component } $this->pull_request_id = null; - $this->resetPaginationAndLoad(); + $this->resetPagination(); } public function setPullRequestFilter(string $pullRequestId): void @@ -217,7 +193,7 @@ class Index extends Component $this->pull_request_id = $pullRequestId === '' ? null : $pullRequestId; $this->deploymentFilters = []; - $this->resetPaginationAndLoad(); + $this->resetPagination(); } public function setDeploymentSort(string $sort): void @@ -227,7 +203,7 @@ class Index extends Component } $this->deploymentSort = $sort; - $this->resetPaginationAndLoad(); + $this->resetPagination(); } public function updatedPullRequestId($value) @@ -248,14 +224,14 @@ class Index extends Component } $this->deploymentFilters = []; - $this->resetPaginationAndLoad(); + $this->resetPagination(); } public function clearFilter() { $this->pull_request_id = null; $this->deploymentFilters = []; - $this->resetPaginationAndLoad(); + $this->resetPagination(); } private function updateCurrentPage() @@ -367,16 +343,30 @@ class Index extends Component ->all(); } - private function resetPaginationAndLoad(): void + private function resetPagination(): void { $this->skip = 0; $this->showPrev = false; $this->updateCurrentPage(); - $this->loadDeployments(); } + /** + * The list is not a public property: Livewire would restore it with SELECT * on every request. + */ public function render() { - return view('livewire.project.application.deployment.index'); + ['deployments' => $deployments, 'count' => $this->deployments_count] = $this->application->deployments( + skip: $this->skip, + take: $this->defaultTake, + pullRequestId: $this->pull_request_id, + search: $this->search, + filters: $this->deploymentFilters, + sort: $this->deploymentSort, + columns: self::HISTORY_COLUMNS, + ); + + return view('livewire.project.application.deployment.index', [ + 'deployments' => $deployments, + ]); } } diff --git a/app/Models/Application.php b/app/Models/Application.php index 1fe908027b..93fd9d695f 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1314,6 +1314,7 @@ class Application extends BaseModel ?string $search = null, array $filters = [], string $sort = 'newest', + array $columns = ['*'], ): array { $deployments = ApplicationDeploymentQueue::query() ->where('application_id', $this->id); @@ -1406,7 +1407,7 @@ class Application extends BaseModel ->orderBy('id', $sort === 'oldest' ? 'asc' : 'desc') ->skip($skip) ->take($take) - ->get(); + ->get($columns); return [ 'count' => $count, diff --git a/database/migrations/2026_10_28_214500_add_index_for_application_deployment_history.php b/database/migrations/2026_10_28_214500_add_index_for_application_deployment_history.php new file mode 100644 index 0000000000..134d74b85d --- /dev/null +++ b/database/migrations/2026_10_28_214500_add_index_for_application_deployment_history.php @@ -0,0 +1,41 @@ +index(['application_id', 'created_at', 'id'], 'idx_deployment_queues_app_created_id'); + }); + } + } + + public function down(): void + { + if (DB::getDriverName() === 'pgsql') { + DB::statement('DROP INDEX CONCURRENTLY IF EXISTS idx_deployment_queues_app_created_id'); + + return; + } + + if (Schema::hasIndex('application_deployment_queues', 'idx_deployment_queues_app_created_id')) { + Schema::table('application_deployment_queues', function (Blueprint $table) { + $table->dropIndex('idx_deployment_queues_app_created_id'); + }); + } + } +}; diff --git a/resources/views/livewire/dashboard/active-deployments.blade.php b/resources/views/livewire/dashboard/active-deployments.blade.php index b6cb5a8358..dc3ab8ed93 100644 --- a/resources/views/livewire/dashboard/active-deployments.blade.php +++ b/resources/views/livewire/dashboard/active-deployments.blade.php @@ -19,8 +19,8 @@ return [$label, $type]; }; - $hasActiveDeployments = $activeDeployments->isNotEmpty(); - $hasRecentDeployments = $recentDeployments->isNotEmpty(); + $hasActiveDeployments = $this->activeDeployments->isNotEmpty(); + $hasRecentDeployments = $this->recentDeployments->isNotEmpty(); $hasAnyDeployments = $hasActiveDeployments || $hasRecentDeployments; @endphp @@ -52,7 +52,7 @@ Started - @foreach ($activeDeployments as $deployment) + @foreach ($this->activeDeployments as $deployment) @php [$deploymentStatus, $deploymentStatusType] = $deploymentStatusMeta($deployment->status); $projectName = $deployment->application?->environment?->project?->name; @@ -112,7 +112,7 @@ Started - @foreach ($recentDeployments as $deployment) + @foreach ($this->recentDeployments as $deployment) @php [$deploymentStatus, $deploymentStatusType] = $deploymentStatusMeta($deployment->status); $projectName = $deployment->application?->environment?->project?->name; diff --git a/tests/Feature/DashboardDeploymentsTest.php b/tests/Feature/DashboardDeploymentsTest.php index d949a62e7e..e35d3a07c3 100644 --- a/tests/Feature/DashboardDeploymentsTest.php +++ b/tests/Feature/DashboardDeploymentsTest.php @@ -12,6 +12,7 @@ use App\Models\StandaloneDocker; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -175,3 +176,24 @@ it('only includes deployments of applications owned by the current team', functi ->assertDontSee('Other Team Deployment') ->assertDontSee('Running or queued right now'); }); + +it('does not load deployment payload columns when the dashboard list polls', function () { + createDashboardDeployment(['logs' => '[{"output":"large deployment log"}]']); + + $deploymentQueries = []; + DB::listen(function ($query) use (&$deploymentQueries) { + if (str_contains($query->sql, 'application_deployment_queues')) { + $deploymentQueries[] = $query->sql; + } + }); + + Livewire::test(ActiveDeployments::class) + ->assertSee('Dashboard App') + ->call('refreshDeployments') + ->assertSee('Dashboard App'); + + expect($deploymentQueries)->not->toBeEmpty(); + foreach ($deploymentQueries as $sql) { + expect($sql)->not->toMatch('/select\s+("?\w+"?\.)?\*/i')->not->toContain('logs'); + } +}); diff --git a/tests/Feature/DeploymentHistoryPayloadTest.php b/tests/Feature/DeploymentHistoryPayloadTest.php new file mode 100644 index 0000000000..76a96006ee --- /dev/null +++ b/tests/Feature/DeploymentHistoryPayloadTest.php @@ -0,0 +1,111 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $this->createDeployment = fn (string $uuid, string $createdAt) => ApplicationDeploymentQueue::query()->forceCreate([ + 'application_id' => $this->application->id, + 'deployment_uuid' => $uuid, + 'server_id' => $server->id, + 'server_name' => $server->name, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + 'commit' => 'abcdef1234567890', + 'commit_message' => "Message of {$uuid}", + 'logs' => '[{"output":"large deployment log"}]', + 'configuration_snapshot' => ['fqdn' => 'https://example.com'], + 'configuration_diff' => ['fqdn' => ['old' => null, 'new' => 'https://example.com']], + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ]); +}); + +it('does not load deployment payload columns for the history list on any Livewire request', function () { + ($this->createDeployment)('deployment-history-row', '2026-10-01 10:00:00'); + + $historyQueries = []; + DB::listen(function ($query) use (&$historyQueries) { + if (str_contains($query->sql, 'application_deployment_queues')) { + $historyQueries[] = $query->sql; + } + }); + + Livewire::test(Index::class, [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'application_uuid' => $this->application->uuid, + 'embedded' => true, + ]) + ->assertSee('deployment-history-row') + ->assertSee('Message of deployment-history-row') + ->call('reloadDeployments') + ->assertSee('deployment-history-row') + ->call('setDeploymentSort', 'oldest') + ->assertSee('deployment-history-row'); + + expect($historyQueries)->not->toBeEmpty(); + foreach ($historyQueries as $sql) { + expect($sql) + ->not->toMatch('/select\s+("?\w+"?\.)?\*/i') + ->not->toContain('logs') + ->not->toContain('configuration_snapshot') + ->not->toContain('configuration_diff'); + } +}); + +it('orders and pages deployments with equal timestamps by id in both directions', function () { + $uuids = collect(['first', 'second', 'third', 'fourth', 'fifth']) + ->each(fn (string $uuid) => ($this->createDeployment)($uuid, '2026-10-01 10:00:00')); + + $page = fn (string $sort, int $skip) => $this->application + ->deployments(skip: $skip, take: 2, sort: $sort, columns: Index::HISTORY_COLUMNS)['deployments'] + ->pluck('deployment_uuid') + ->all(); + + expect([...$page('newest', 0), ...$page('newest', 2), ...$page('newest', 4)])->toBe($uuids->reverse()->values()->all()) + ->and([...$page('oldest', 0), ...$page('oldest', 2), ...$page('oldest', 4)])->toBe($uuids->all()); +}); + +it('keeps the full deployment payload for callers that do not restrict the columns', function () { + ($this->createDeployment)('full-deployment', '2026-10-01 10:00:00'); + + $listed = $this->application->deployments(columns: Index::HISTORY_COLUMNS)['deployments']->sole(); + $full = $this->application->deployments()['deployments']->sole(); + + expect($listed->getAttributes())->not->toHaveKeys(['logs', 'configuration_snapshot', 'configuration_diff']) + ->and($full->logs)->toBe('[{"output":"large deployment log"}]') + ->and($full->configuration_snapshot)->toBe(['fqdn' => 'https://example.com']); +});