diff --git a/app/Livewire/Dashboard/ActiveDeployments.php b/app/Livewire/Dashboard/ActiveDeployments.php
index 74274d434d..e3f9ab21e1 100644
--- a/app/Livewire/Dashboard/ActiveDeployments.php
+++ b/app/Livewire/Dashboard/ActiveDeployments.php
@@ -4,42 +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)
- ->visibleToCurrentActor();
-
- $this->activeDeployments = (clone $baseQuery)
+ return $this->deploymentsQuery()
->whereIn('status', [
ApplicationDeploymentStatus::IN_PROGRESS->value,
ApplicationDeploymentStatus::QUEUED->value,
@@ -47,16 +44,28 @@ 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)
+ ->visibleToCurrentActor();
}
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 2a32c515e0..5b737db56a 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -1320,6 +1320,7 @@ class Application extends BaseModel
?string $search = null,
array $filters = [],
string $sort = 'newest',
+ array $columns = ['*'],
): array {
$deployments = ApplicationDeploymentQueue::query()
->where('application_id', $this->id);
@@ -1412,7 +1413,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']);
+});