From 95d796e922a3a9f5692ddf159c494bf7faa79c23 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:34:08 +0200 Subject: [PATCH] feat(deployments): compact history beside logs with in-place switching - Fit the embedded history to the logs height and page size to the screen - Show status as a dot with short start time; add source, duration, and server as a second line; make whole rows clickable - Switch the logs viewer to another deployment without a page load - Use View and Export menus and the runtime logs search in the toolbar - Export visible logs from the server with secrets hidden; redact app secrets in the full log download Co-Authored-By: Claude Opus 5.5 --- .../Project/Application/Deployment/Index.php | 15 +- .../Project/Application/Deployment/Show.php | 95 +++++-- resources/css/app.css | 71 +++-- .../application/deployment/index.blade.php | 108 ++++++-- .../application/deployment/show.blade.php | 255 ++++++++++-------- tests/Feature/DeploymentLogsLayoutTest.php | 84 ++++++ 6 files changed, 450 insertions(+), 178 deletions(-) diff --git a/app/Livewire/Project/Application/Deployment/Index.php b/app/Livewire/Project/Application/Deployment/Index.php index 1c8bc27ca3..08194f6bfc 100644 --- a/app/Livewire/Project/Application/Deployment/Index.php +++ b/app/Livewire/Project/Application/Deployment/Index.php @@ -7,6 +7,7 @@ use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Traits\ListensToTeamChannel; use Illuminate\Support\Collection; +use Livewire\Attributes\On; use Livewire\Component; class Index extends Component @@ -68,18 +69,24 @@ class Index extends Component ]); } - public function mount() + #[On('deploymentSelected')] + public function setSelectedDeployment(string $deploymentUuid): void { - $project = currentTeam()->load(['projects'])->projects->where('uuid', request()->route('project_uuid'))->first(); + $this->selectedDeploymentUuid = $deploymentUuid; + } + + public function mount(string $project_uuid, string $environment_uuid, string $application_uuid) + { + $project = currentTeam()->load(['projects'])->projects->where('uuid', $project_uuid)->first(); if (! $project) { return redirect()->route('dashboard'); } - $environment = $project->load(['environments'])->environments->where('uuid', request()->route('environment_uuid'))->first(); + $environment = $project->load(['environments'])->environments->where('uuid', $environment_uuid)->first(); if (! $environment) { abort(404); } $environment->load(['applications']); - $application = $environment->applications->where('uuid', request()->route('application_uuid'))->first(); + $application = $environment->applications->where('uuid', $application_uuid)->first(); if (! $application) { return redirect()->route('dashboard'); } diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php index 338a15d023..5227784b6d 100644 --- a/app/Livewire/Project/Application/Deployment/Show.php +++ b/app/Livewire/Project/Application/Deployment/Show.php @@ -4,6 +4,7 @@ namespace App\Livewire\Project\Application\Deployment; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; +use App\Models\EnvironmentVariable; use App\Traits\AuditsApplicationSettings; use Livewire\Component; @@ -34,20 +35,20 @@ class Show extends Component ]; } - public function mount() + public function mount(string $project_uuid, string $environment_uuid, string $application_uuid, string $deployment_uuid) { - $deploymentUuid = request()->route('deployment_uuid'); + $deploymentUuid = $deployment_uuid; - $project = currentTeam()->load(['projects'])->projects->where('uuid', request()->route('project_uuid'))->first(); + $project = currentTeam()->load(['projects'])->projects->where('uuid', $project_uuid)->first(); if (! $project) { return redirect()->route('dashboard'); } - $environment = $project->load(['environments'])->environments->where('uuid', request()->route('environment_uuid'))->first(); + $environment = $project->load(['environments'])->environments->where('uuid', $environment_uuid)->first(); if (! $environment) { abort(404); } $environment->load(['applications']); - $application = $environment->applications->where('uuid', request()->route('application_uuid'))->first(); + $application = $environment->applications->where('uuid', $application_uuid)->first(); if (! $application) { return redirect()->route('dashboard'); } @@ -71,6 +72,34 @@ class Show extends Component $this->isKeepAliveOn(); } + /** + * Swap the logs viewer to another deployment of this application without a page load. + */ + public function selectDeployment(string $deploymentUuid): void + { + $deployment = ApplicationDeploymentQueue::where('deployment_uuid', $deploymentUuid) + ->where('application_id', $this->application->id) + ->first(); + if (! $deployment) { + return; + } + + $this->application_deployment_queue = $deployment; + $this->deployment_uuid = $deploymentUuid; + $this->horizon_job_status = $deployment->getHorizonJobStatus(); + $this->isKeepAliveOn(); + + $this->dispatch('deploymentSelected', deploymentUuid: $deploymentUuid)->to(Index::class); + + $url = route('project.application.deployment.show', [ + 'project_uuid' => $this->application->environment->project->uuid, + 'environment_uuid' => $this->application->environment->uuid, + 'application_uuid' => $this->application->uuid, + 'deployment_uuid' => $deploymentUuid, + ]); + $this->js('history.replaceState(history.state, "", '.json_encode($url).')'); + } + public function toggleDebug() { try { @@ -116,25 +145,57 @@ class Show extends Component return decode_remote_command_output($this->application_deployment_queue); } + /** + * The displayed log lines that match the search, with secrets hidden. + */ + public function exportLogs(bool $withTimestamps = true, string $search = ''): string + { + $this->authorize('view', $this->application); + + $search = mb_strtolower(trim($search)); + $lines = $this->logLines + ->map(fn (array $line): array => [ + 'timestamp' => $line['timestamp'], + 'text' => $this->exportLineText($line), + ]) + ->filter(fn (array $line): bool => $search === '' || str_contains(mb_strtolower($line['timestamp'].' '.$line['text']), $search)) + ->map(fn (array $line): string => $withTimestamps ? $line['timestamp'].' '.$line['text'] : $line['text']) + ->join("\n"); + + return sanitizeLogsForExport($lines, $this->logRedactionSecrets()); + } + + /** + * Every log line, also the debug lines, with secrets hidden. + */ public function downloadAllLogs(): string { $this->authorize('update', $this->application); $logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true) - ->map(function ($line) { - $prefix = ''; - if ($line['hidden']) { - $prefix = '[DEBUG] '; - } - if (isset($line['command']) && $line['command']) { - $prefix .= '[CMD]: '; - } - - return $line['timestamp'].' '.$prefix.trim($line['line']); - }) + ->map(fn (array $line): string => $line['timestamp'].' '.($line['hidden'] ? '[DEBUG] ' : '').$this->exportLineText($line)) ->join("\n"); - return sanitizeLogsForExport($logs); + return sanitizeLogsForExport($logs, $this->logRedactionSecrets()); + } + + /** + * @param array{line: string, command?: bool|null} $line + */ + private function exportLineText(array $line): string + { + return (! empty($line['command']) ? '[CMD]: ' : '').trim($line['line']); + } + + /** + * @return array{known: list, locked: list} + */ + private function logRedactionSecrets(): array + { + return EnvironmentVariable::logRedactionSecretsFor( + $this->application, + includePreview: (int) $this->application_deployment_queue->pull_request_id !== 0, + ); } public function render() diff --git a/resources/css/app.css b/resources/css/app.css index 1e68d0042e..72302ad6c6 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -3523,17 +3523,61 @@ input[type="search"]::-webkit-search-results-decoration { } } +/* A full-row link sits behind the cells, so gaps open the deployment too; the cells stay on top for their own links and tooltips. */ +.deployment-table-row > :not(.deployment-row-link) { + position: relative; + z-index: 1; + pointer-events: none; +} + +.deployment-table-row a:not(.deployment-row-link) { + pointer-events: auto; +} + +/* The embedded history shows the start time and a status dot in the last column. */ +.deployment-table-embedded .deployment-table-grid { + grid-template-columns: minmax(7rem, 0.8fr) minmax(12rem, 1.7fr) 6.5rem minmax(7rem, 0.8fr) 5rem; +} + @media (min-width: 1536px) { .deployment-table-embedded .deployment-table-grid { - grid-template-columns: 6.5rem minmax(0, 1fr) 6.5rem; + grid-template-columns: minmax(0, 1fr) 5rem; } - .deployment-table-embedded .deployment-table-grid > :nth-child(2), - .deployment-table-embedded .deployment-table-grid > :nth-child(n + 5) { + .deployment-table-embedded .deployment-table-grid > :nth-child(1), + .deployment-table-embedded .deployment-table-grid > :nth-child(3), + .deployment-table-embedded .deployment-table-grid > :nth-child(4) { display: none; } } +/* On the deployment page the history matches the logs height; the page size is fitted to it in JS. */ +@media (min-width: 1536px) { + .deployment-history-fit { + position: sticky; + top: 1rem; + display: flex; + flex-direction: column; + height: calc(100dvh - 10rem); + min-height: 32rem; + } + + .deployment-history-fit > .application-settings-section, + .deployment-history-fit .application-settings-section-body, + .deployment-history-fit .data-table { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + } + + .deployment-history-fit .deployment-table-scroll { + flex: 1; + min-height: 0; + overflow: hidden; + } +} + /* Status and Started only fit a badge and a short "3 hours ago"; the names get the rest. */ .dashboard-deployment-table-grid { grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 0.7fr) 6.5rem 6rem; @@ -3995,24 +4039,6 @@ html[data-theme="custom"] .logs-viewer-timestamp { opacity: 0.5; } -/* Compact badge that fits the dark logs chrome */ -.logs-viewer-status-badge { - height: 1.5rem !important; - flex-shrink: 0; - border-color: var(--color-neutral-200) !important; - background: white !important; - padding-inline: 0.5rem !important; - font-size: 0.6875rem !important; - font-weight: 500 !important; - color: var(--color-neutral-700) !important; -} - -.dark .logs-viewer-status-badge { - border-color: rgba(255, 255, 255, 0.1) !important; - background: rgba(255, 255, 255, 0.05) !important; - color: #d4d4d4 !important; -} - /* Section header status (proxy/sentinel) stays inline with title on mobile */ .logs-settings-section > :is(header, .application-settings-section-header) { flex-wrap: nowrap; @@ -4341,7 +4367,8 @@ html.dark:not([data-theme="custom"]) .runtime-log-panel { box-shadow: 0 0 0 1px rgb(255 255 255 / 8%); } -html.dark:not([data-theme="custom"]) .runtime-log-panel :is(.runtime-log-toolbar, .runtime-log-columns) { +html.dark:not([data-theme="custom"]) .runtime-log-panel :is(.runtime-log-toolbar, .runtime-log-columns), +html.dark:not([data-theme="custom"]) .logs-viewer > .runtime-log-toolbar { border-bottom-color: rgb(255 255 255 / 8%); background: color-mix(in srgb, var(--color-card) 66%, black); } diff --git a/resources/views/livewire/project/application/deployment/index.blade.php b/resources/views/livewire/project/application/deployment/index.blade.php index 8b0c7e56e9..f7875deba2 100644 --- a/resources/views/livewire/project/application/deployment/index.blade.php +++ b/resources/views/livewire/project/application/deployment/index.blade.php @@ -26,8 +26,26 @@ current-route="project.application.deployment.index" /> @endunless -
+
$embedded]) + @if (!$skip) wire:poll.5000ms="reloadDeployments" @endif + @if ($embedded) + x-data="{ + selecting: null, + fit() { + const scroll = this.$el.querySelector('.deployment-table-scroll'); + const header = scroll?.querySelector('.data-table-header'); + const row = scroll?.querySelector('.data-table-row'); + if (!row) return; + const take = window.matchMedia('(min-width: 1536px)').matches + ? Math.max(1, Math.floor((scroll.clientHeight - header.offsetHeight) / row.offsetHeight)) + : 3; + if (take !== $wire.defaultTake) $wire.set('defaultTake', take); + }, + }" + x-init="let timer; + new ResizeObserver(() => { clearTimeout(timer); timer = setTimeout(() => fit(), 100); }).observe($el); + $wire.$hook('commit', ({ succeed }) => succeed(() => $nextTick(() => fit())))" + @endif> @@ -169,14 +187,28 @@ -
$embedded])> +
$embedded]) + @if ($embedded) + @click="const link = $event.target.closest('a[data-select-deployment]'); + if (!link || $event.button !== 0 || $event.metaKey || $event.ctrlKey || $event.shiftKey || $event.altKey) return; + $event.preventDefault(); + selecting = link.dataset.selectDeployment; + $wire.$parent.selectDeployment(selecting).finally(() => selecting = null)" + @endif>
- Status + @unless ($embedded) + Status + @endunless Source Commit - Started + @unless ($embedded) + Started + @endunless Duration Server + @if ($embedded) + Status + @endif
@foreach ($deployments as $deployment) @@ -215,43 +247,83 @@ ? Str::before($deployment->commitMessage(), "\n") : null; $deploymentUrl = $current_url . '/' . data_get($deployment, 'deployment_uuid'); + $deploymentLinkAttributes = $embedded + ? 'data-select-deployment="'.e(data_get($deployment, 'deployment_uuid')).'"' + : wireNavigate(); $commitUrl = data_get($deployment, 'commit') ? $application->gitCommitLink(data_get($deployment, 'commit')) : null; @endphp
$selectedDeploymentUuid === data_get($deployment, 'deployment_uuid'), - ])> - - {{ $sourceLabel }} + ]) + @if ($embedded) + x-bind:class="selecting === '{{ data_get($deployment, 'deployment_uuid') }}' && 'data-table-row-active'" + x-bind:aria-busy="selecting === '{{ data_get($deployment, 'deployment_uuid') }}'" + @endif> + @unless ($embedded) + + @endunless + {{ $sourceLabel }} @if (data_get($deployment, 'commit')) + class="shrink-0 font-mono text-xs text-neutral-950 underline decoration-neutral-400 underline-offset-2 dark:text-fg dark:decoration-neutral-600 group-hover:text-neutral-950 dark:group-hover:text-white"> {{ substr(data_get($deployment, 'commit'), 0, 7) }} @if ($commitMessage) - {{ $commitMessage }} @endif @else - - + - + @endif + @if ($embedded) + @endif - - {{ \Carbon\Carbon::parse(data_get($deployment, 'created_at'))->diffForHumans() }} - - {{ $duration }} - + @unless ($embedded) + + {{ \Carbon\Carbon::parse(data_get($deployment, 'created_at'))->diffForHumans() }} + + @endunless + {{ $duration }} + {{ data_get($deployment, 'server_name') ?: data_get($application, 'destination.server.name', '-') }} + @if ($embedded) + + {{ \Carbon\Carbon::parse(data_get($deployment, 'created_at'))->diffForHumans(syntax: \Carbon\CarbonInterface::DIFF_ABSOLUTE, short: true) }} + + $statusType === 'success', + 'bg-warning motion-safe:animate-pulse' => $statusType === 'warning', + 'bg-red-500' => $statusType === 'error', + 'bg-neutral-400 dark:bg-neutral-500' => $statusType === 'neutral', + ])> + + @endif +
@endforeach + + @if ($embedded) + + @endif
-
+
+ wire:key="embedded-deployment-history" /> -
+ }" @keydown.window="handleFindShortcut($event)" class="flex h-[calc(100dvh-8rem)] min-h-[32rem] w-full min-w-0 flex-col overflow-hidden xl:h-[32rem] xl:min-h-0 xl:flex-none 2xl:sticky 2xl:top-4 2xl:h-[calc(100dvh-10rem)] 2xl:min-h-[32rem]">
-
+
@php $deploymentStatus = data_get($application_deployment_queue, 'status'); $deploymentStatusLabel = $deploymentStatus === 'in_progress' @@ -294,15 +303,7 @@
- + @if ($isKeepAliveOn) - @can('update', $application) - - @endcan + @endif - -
- -
-
- - @can('update', $application) - - @endcan -
-
-
- + + $deploymentStatusType === 'success', + 'bg-warning motion-safe:animate-pulse' => $deploymentStatusType === 'warning', + 'bg-red-500' => $deploymentStatusType === 'error', + 'bg-neutral-400 dark:bg-neutral-500' => $deploymentStatusType === 'neutral', + ])> + {{ $deploymentStatusLabel }} +
+
+ + +
+

Display

+ + @can('update', $application) + + @endcan +
+
+ + +
+

Copied and downloaded logs hide secrets.

+ + + @can('update', $application) + + @endcan +
+
+
diff --git a/tests/Feature/DeploymentLogsLayoutTest.php b/tests/Feature/DeploymentLogsLayoutTest.php index f3c61cbcf7..264ea324a4 100644 --- a/tests/Feature/DeploymentLogsLayoutTest.php +++ b/tests/Feature/DeploymentLogsLayoutTest.php @@ -1,9 +1,11 @@ ApplicationDeploymentQueue::create([ + 'application_id' => $application->id, + 'deployment_uuid' => $uuid, + 'server_id' => $this->server->id, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + ]); + $first = $makeDeployment($this->application, 'deploy-first'); + $second = $makeDeployment($this->application, 'deploy-second'); + $otherApplication = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $foreign = $makeDeployment($otherApplication, 'deploy-foreign'); + + Livewire::test(Show::class, [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'application_uuid' => $this->application->uuid, + 'deployment_uuid' => $first->deployment_uuid, + ]) + ->assertSet('deployment_uuid', 'deploy-first') + ->call('selectDeployment', $second->deployment_uuid) + ->assertSet('deployment_uuid', 'deploy-second') + ->assertSet('application_deployment_queue.id', $second->id) + ->assertDispatched('deploymentSelected', deploymentUuid: 'deploy-second') + ->call('selectDeployment', $foreign->deployment_uuid) + ->assertSet('deployment_uuid', 'deploy-second'); +}); + +it('exports the displayed deployment logs with secrets hidden and the full log with debug lines', function () { + EnvironmentVariable::query()->create([ + 'key' => 'DEPLOY_SECRET', + 'value' => 'deploy-export-canary', + 'resourceable_type' => $this->application->getMorphClass(), + 'resourceable_id' => $this->application->id, + 'is_preview' => false, + 'is_shown_once' => true, + ]); + $line = fn (string $output, int $order, bool $hidden = false) => [ + 'command' => null, + 'output' => $output, + 'type' => 'stdout', + 'timestamp' => now()->toISOString(), + 'hidden' => $hidden, + 'batch' => 1, + 'order' => $order, + ]; + $deployment = ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => 'deploy-export', + 'server_id' => $this->server->id, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + 'logs' => json_encode([ + $line('build uses deploy-export-canary', 1), + $line('pushing image', 2), + $line('internal debug step', 3, hidden: true), + ], JSON_THROW_ON_ERROR), + ]); + + $component = Livewire::test(Show::class, [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'application_uuid' => $this->application->uuid, + 'deployment_uuid' => $deployment->deployment_uuid, + ]); + + $visible = $component->instance()->exportLogs(withTimestamps: false); + expect($visible)->toContain('build uses') + ->toContain('pushing image') + ->not->toContain('deploy-export-canary') + ->not->toContain('internal debug step'); + + expect($component->instance()->exportLogs(withTimestamps: false, search: 'PUSHING'))->toBe('pushing image'); + + $full = $component->instance()->downloadAllLogs(); + expect($full)->toContain('[DEBUG] internal debug step') + ->not->toContain('deploy-export-canary'); +});