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 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-23 12:34:08 +02:00
co-authored by Claude Opus 5.5
parent 3bfeb14b7d
commit 95d796e922
6 changed files with 450 additions and 178 deletions
@@ -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');
}
@@ -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<string>, locked: list<string>}
*/
private function logRedactionSecrets(): array
{
return EnvironmentVariable::logRedactionSecretsFor(
$this->application,
includePreview: (int) $this->application_deployment_queue->pull_request_id !== 0,
);
}
public function render()
+49 -22
View File
@@ -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);
}
@@ -26,8 +26,26 @@
current-route="project.application.deployment.index" />
@endunless
<div class="application-settings-form min-w-0"
@if (!$skip) wire:poll.5000ms="reloadDeployments" @endif>
<div @class(['application-settings-form min-w-0', 'deployment-history-fit' => $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>
<x-application.settings-section title="Deployment history"
helper="Search, filter, and open a deployment to inspect its build logs." flush>
<x-table.toolbar class="border-b border-neutral-200 p-3 dark:border-white/[0.08]">
@@ -169,14 +187,28 @@
<x-table.loading id="deployment-table-filter-loading"
target="toggleDeploymentFilter,clearFilter,setPullRequestFilter"
text="Filtering deployments..." class="rounded-lg" />
<div @class(['deployment-table-scroll', 'deployment-table-embedded' => $embedded])>
<div @class(['deployment-table-scroll', 'deployment-table-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>
<div class="data-table-header deployment-table-grid rounded-none!">
<span>Status</span>
@unless ($embedded)
<span>Status</span>
@endunless
<span>Source</span>
<span>Commit</span>
<span>Started</span>
@unless ($embedded)
<span>Started</span>
@endunless
<span>Duration</span>
<span>Server</span>
@if ($embedded)
<span class="text-right">Status</span>
@endif
</div>
@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
<div wire:key="deployment-{{ data_get($deployment, 'deployment_uuid') }}"
@class([
'data-table-row deployment-table-grid border-b border-neutral-200 text-[13px] text-neutral-600 dark:border-white/[0.08] dark:text-fg-dim',
'data-table-row deployment-table-row deployment-table-grid group relative border-b border-neutral-200 text-[13px] text-neutral-600 hover:text-neutral-950 dark:border-white/[0.08] dark:text-fg-dim dark:hover:text-white',
'data-table-row-active' => $selectedDeploymentUuid === data_get($deployment, 'deployment_uuid'),
])>
<a href="{{ $deploymentUrl }}" {{ wireNavigate() }}><x-status-badge :status="$statusLabel" :type="$statusType" /></a>
<a href="{{ $deploymentUrl }}" {{ wireNavigate() }}>{{ $sourceLabel }}</a>
])
@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)
<a href="{{ $deploymentUrl }}" {!! $deploymentLinkAttributes !!}><x-status-badge :status="$statusLabel" :type="$statusType" /></a>
@endunless
<a href="{{ $deploymentUrl }}" {!! $deploymentLinkAttributes !!}>{{ $sourceLabel }}</a>
<span class="min-w-0">
@if (data_get($deployment, 'commit'))
<span class="flex min-w-0 items-center gap-2">
<a href="{{ $commitUrl }}" target="_blank" rel="noopener noreferrer"
class="shrink-0 font-mono text-xs text-neutral-950 underline decoration-neutral-400 underline-offset-2 dark:text-fg dark:decoration-neutral-600">
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) }}
</a>
@if ($commitMessage)
<a href="{{ $deploymentUrl }}" {{ wireNavigate() }}
class="truncate text-neutral-500 dark:text-fg-faint"
<a href="{{ $deploymentUrl }}" {!! $deploymentLinkAttributes !!}
class="truncate text-neutral-500 dark:text-fg-faint group-hover:text-neutral-950 dark:group-hover:text-white"
title="{{ $commitMessage }}">{{ $commitMessage }}</a>
@endif
</span>
@else
<span class="text-neutral-400 dark:text-fg-faint">-</span>
<span class="text-neutral-400 dark:text-fg-faint group-hover:text-neutral-950 dark:group-hover:text-white">-</span>
@endif
@if ($embedded)
<span class="mt-0.5 hidden truncate text-xs text-neutral-500 group-hover:text-neutral-950 dark:text-fg-faint dark:group-hover:text-white 2xl:block">
{{ $sourceLabel }} · <span class="tabular-nums">{{ $duration }}</span> · {{ data_get($deployment, 'server_name') ?: data_get($application, 'destination.server.name', '-') }}
</span>
@endif
</span>
<a href="{{ $deploymentUrl }}" {{ wireNavigate() }} title="{{ formatDateInServerTimezone(data_get($deployment, 'created_at'), data_get($application, 'destination.server')) }}">
{{ \Carbon\Carbon::parse(data_get($deployment, 'created_at'))->diffForHumans() }}
</a>
<a href="{{ $deploymentUrl }}" {{ wireNavigate() }} class="tabular-nums">{{ $duration }}</a>
<a href="{{ $deploymentUrl }}" {{ wireNavigate() }} class="truncate">
@unless ($embedded)
<a href="{{ $deploymentUrl }}" {!! $deploymentLinkAttributes !!} title="{{ formatDateInServerTimezone(data_get($deployment, 'created_at'), data_get($application, 'destination.server')) }}">
{{ \Carbon\Carbon::parse(data_get($deployment, 'created_at'))->diffForHumans() }}
</a>
@endunless
<a href="{{ $deploymentUrl }}" {!! $deploymentLinkAttributes !!} class="tabular-nums">{{ $duration }}</a>
<a href="{{ $deploymentUrl }}" {!! $deploymentLinkAttributes !!} class="truncate">
{{ data_get($deployment, 'server_name') ?: data_get($application, 'destination.server.name', '-') }}
</a>
@if ($embedded)
<a href="{{ $deploymentUrl }}" {!! $deploymentLinkAttributes !!}
class="flex items-center justify-end gap-2 pr-1 tabular-nums"
title="{{ $statusLabel }} · {{ formatDateInServerTimezone(data_get($deployment, 'created_at'), data_get($application, 'destination.server')) }}">
<span>{{ \Carbon\Carbon::parse(data_get($deployment, 'created_at'))->diffForHumans(syntax: \Carbon\CarbonInterface::DIFF_ABSOLUTE, short: true) }}</span>
<x-loading compact x-cloak x-show="selecting === '{{ data_get($deployment, 'deployment_uuid') }}'"
aria-label="Loading deployment logs..." />
<span x-show="selecting !== '{{ data_get($deployment, 'deployment_uuid') }}'" @class([
'size-2 shrink-0 rounded-full',
'bg-emerald-500' => $statusType === 'success',
'bg-warning motion-safe:animate-pulse' => $statusType === 'warning',
'bg-red-500' => $statusType === 'error',
'bg-neutral-400 dark:bg-neutral-500' => $statusType === 'neutral',
])></span>
</a>
@endif
<a href="{{ $deploymentUrl }}" {!! $deploymentLinkAttributes !!} class="deployment-row-link absolute inset-0"
aria-hidden="true" tabindex="-1"></a>
</div>
@endforeach
@if ($embedded)
<div wire:loading.flex wire:target="defaultTake" class="hidden justify-center py-6">
<x-loading aria-label="Loading deployments..." class="[&_.loading-indicator]:size-5" />
</div>
@endif
</div>
<x-table-pagination :from="$firstVisibleRow" :to="$lastVisibleRow" :total="$deployments_count"
@@ -8,12 +8,15 @@
<div class="grid min-h-0 min-w-0 flex-1 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<x-application.configuration-sidebar :application="$application"
current-route="project.application.deployment.show" />
<div class="flex min-h-0 min-w-0 flex-col gap-4 xl:h-full xl:pt-px 2xl:grid 2xl:grid-cols-[24rem_minmax(0,1fr)] 2xl:items-start">
<div class="flex min-h-0 min-w-0 flex-col gap-4 xl:h-full xl:pt-px 2xl:grid 2xl:grid-cols-[30rem_minmax(0,1fr)] 2xl:items-start">
<livewire:project.application.deployment.index :embedded="true"
:project_uuid="$application->environment->project->uuid"
:environment_uuid="$application->environment->uuid"
:application_uuid="$application->uuid"
:selected-deployment-uuid="$deployment_uuid"
wire:key="embedded-deployment-history-{{ $deployment_uuid }}" />
wire:key="embedded-deployment-history" />
<div x-data="{
<div wire:key="deployment-logs-{{ $deployment_uuid }}" wire:loading.class="opacity-60" wire:target="selectDeployment" x-data="{
fullscreen: @entangle('fullscreen'),
alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }},
followManuallyDisabled: false,
@@ -76,6 +79,17 @@
}
this.lastTouchY = currentY;
},
findShortcutLabel: /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘F' : 'Ctrl+F',
handleFindShortcut(event) {
const isFind = (event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key?.toLowerCase() === 'f';
if (!isFind) return;
const active = document.activeElement;
if (active === this.$refs.search) return;
if (!this.$root.contains(active) && (active?.isContentEditable || active?.matches('input, textarea, select'))) return;
event.preventDefault();
this.$refs.search.focus();
this.$refs.search.select();
},
handleKeyScroll(event) {
if (!this.alwaysScroll) return;
const upKeys = ['ArrowUp', 'PageUp', 'Home'];
@@ -177,36 +191,31 @@
this.matchCount = query ? count : 0;
},
collectVisibleLogs() {
const logs = document.getElementById('logs');
if (!logs) return '';
const visibleLines = logs.querySelectorAll('[data-log-line]:not(.hidden)');
let content = '';
visibleLines.forEach(line => {
const text = line.textContent.replace(/\s+/g, ' ').trim();
if (text) {
content += text + String.fromCharCode(10);
}
});
return content;
async exportLogs() {
try {
return await this.$wire.exportLogs(this.showTimestamps, this.searchQuery.trim());
} catch {
Livewire.dispatch('error', ['Failed to prepare logs for export.']);
return '';
}
},
copyLogs() {
const content = this.collectVisibleLogs();
async copyLogs() {
const content = await this.exportLogs();
if (!content) return;
if (!navigator.clipboard?.writeText) {
Livewire.dispatch('error', ['Clipboard is not available. Please use HTTPS or localhost.']);
return;
}
navigator.clipboard?.writeText(content).then(() => {
navigator.clipboard.writeText(content).then(() => {
Livewire.dispatch('success', ['Logs copied to clipboard.']);
}).catch(() => {
Livewire.dispatch('error', ['Failed to copy logs to clipboard.']);
});
},
downloadLogs() {
const content = this.collectVisibleLogs();
async downloadLogs() {
const content = await this.exportLogs();
if (!content) return;
const blob = new Blob([content], { type: 'text/plain' });
const blob = new Blob([content + String.fromCharCode(10)], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -273,12 +282,12 @@
this.deploymentFinishedCleanup = null;
}
}
}" 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]">
}" @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]">
<div id="screen" :class="fullscreen ? 'fullscreen flex flex-col' : 'mt-2 flex flex-1 min-h-0 flex-col overflow-hidden lg:mt-0'">
<div @if ($isKeepAliveOn) wire:poll.2000ms="polling" @endif
class="logs-viewer flex min-h-0 w-full flex-col overflow-hidden bg-white text-neutral-800 dark:bg-log dark:text-neutral-100"
:class="fullscreen ? 'h-full' : 'flex-1 rounded-xl border border-neutral-200 shadow-sm dark:border-coolgray-200'">
<div class="logs-viewer-toolbar">
<div class="runtime-log-toolbar logs-viewer-toolbar">
@php
$deploymentStatus = data_get($application_deployment_queue, 'status');
$deploymentStatusLabel = $deploymentStatus === 'in_progress'
@@ -294,15 +303,7 @@
<div class="logs-viewer-toolbar-controls">
<div class="logs-viewer-primary">
<div class="logs-viewer-actions">
<button title="Toggle Timestamps" x-on:click="showTimestamps = !showTimestamps"
:class="showTimestamps ? 'logs-viewer-btn-active' : ''"
class="logs-viewer-btn">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</button>
@if ($isKeepAliveOn)
<button title="Follow Logs" :class="alwaysScroll ? 'logs-viewer-btn-active' : ''"
x-on:click="toggleScroll"
class="logs-viewer-btn">
@@ -311,17 +312,7 @@
stroke-linejoin="round" stroke-width="2" d="M12 5v14m4-4l-4 4m-4-4l4 4" />
</svg>
</button>
@can('update', $application)
<button wire:click="toggleDebug"
title="{{ $is_debug_enabled ? 'Hide Debug Logs' : 'Show Debug Logs' }}"
class="logs-viewer-btn {{ $is_debug_enabled ? 'logs-viewer-btn-active' : '' }}">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 12.75c1.148 0 2.278.08 3.383.237 1.037.146 1.866.966 1.866 2.013 0 3.728-2.35 6.75-5.25 6.75S6.75 18.728 6.75 15c0-1.046.83-1.867 1.866-2.013A24.204 24.204 0 0 1 12 12.75Zm0 0c2.883 0 5.647.508 8.207 1.44a23.91 23.91 0 0 1-1.152 6.06M12 12.75c-2.883 0-5.647.508-8.208 1.44.125 2.104.52 4.136 1.153 6.06M12 12.75a2.25 2.25 0 0 0 2.248-2.354M12 12.75a2.25 2.25 0 0 1-2.248-2.354M12 8.25c.995 0 1.971-.08 2.922-.236.403-.066.74-.358.795-.762a3.778 3.778 0 0 0-.399-2.25M12 8.25c-.995 0-1.97-.08-2.922-.236-.402-.066-.74-.358-.795-.762a3.734 3.734 0 0 1 .4-2.253M12 8.25a2.25 2.25 0 0 0-2.248 2.146M12 8.25a2.25 2.25 0 0 1 2.248 2.146M8.683 5a6.032 6.032 0 0 1-1.155-1.002c.07-.63.27-1.222.574-1.747m.581 2.749A3.75 3.75 0 0 1 15.318 5m0 0c.427-.283.815-.62 1.155-.999a4.471 4.471 0 0 0-.575-1.752M4.921 6a24.048 24.048 0 0 0-.392 3.314c1.668.546 3.416.914 5.223 1.082M19.08 6c.205 1.08.337 2.187.392 3.314a23.882 23.882 0 0 1-5.223 1.082" />
</svg>
</button>
@endcan
@endif
<button title="Fullscreen" x-show="!fullscreen" x-on:click="makeFullscreen"
class="logs-viewer-btn">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
@@ -341,92 +332,122 @@
d="M6 14h4m0 0v4m0-4l-6 6m14-10h-4m0 0V6m0 4l6-6" />
</svg>
</button>
<button
x-on:click="copyLogs()"
title="Copy Logs"
class="logs-viewer-btn">
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
</svg>
</button>
<div x-data="{ downloadMenuOpen: false, downloadingAllLogs: false }" class="relative shrink-0">
<button x-on:click="downloadMenuOpen = !downloadMenuOpen" title="Download Logs"
class="logs-viewer-btn">
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</button>
<div x-show="downloadMenuOpen" x-on:click.away="downloadMenuOpen = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="absolute right-0 z-50 mt-2 w-max origin-top-right rounded-lg border border-neutral-200 bg-white p-1 shadow-dropdown focus:outline-none dark:border-white/[0.1] dark:bg-[#181818]">
<div>
<button x-on:click="downloadLogs(); downloadMenuOpen = false"
class="listbox-option text-neutral-700! hover:bg-neutral-100! dark:text-neutral-200! dark:hover:bg-white/[0.07]!">
Download displayed logs
</button>
@can('update', $application)
<button x-on:click="
downloadingAllLogs = true;
$wire.downloadAllLogs().then(logs => {
if (!logs) return;
const blob = new Blob([logs], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const timestamp = new Date().toISOString().slice(0,19).replace(/[T:]/g, '-');
a.download = 'deployment-' + deploymentId + '-all-logs-' + timestamp + '.txt';
a.click();
URL.revokeObjectURL(url);
Livewire.dispatch('success', ['All logs downloaded.']);
}).finally(() => {
downloadingAllLogs = false;
downloadMenuOpen = false;
});
"
:disabled="downloadingAllLogs"
:class="{ 'opacity-50 cursor-not-allowed': downloadingAllLogs }"
class="listbox-option text-neutral-700! hover:bg-neutral-100! dark:text-neutral-200! dark:hover:bg-white/[0.07]!">
<span x-show="!downloadingAllLogs">Download all logs</span>
<span x-show="downloadingAllLogs" class="flex items-center gap-2">
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Downloading...
</span>
</button>
@endcan
</div>
</div>
</div>
</div>
<x-status-badge :status="$deploymentStatusLabel" :type="$deploymentStatusType"
class="logs-viewer-status-badge" />
<span class="flex shrink-0 items-center gap-1.5 text-xs font-medium text-neutral-600 dark:text-fg-dim">
<span @class([
'size-2 shrink-0 rounded-full',
'bg-emerald-500' => $deploymentStatusType === 'success',
'bg-warning motion-safe:animate-pulse' => $deploymentStatusType === 'warning',
'bg-red-500' => $deploymentStatusType === 'error',
'bg-neutral-400 dark:bg-neutral-500' => $deploymentStatusType === 'neutral',
])></span>
{{ $deploymentStatusLabel }}
</span>
</div>
<div class="logs-viewer-end">
<livewire:project.application.deployment-navbar
:application_deployment_queue="$application_deployment_queue" />
<div class="logs-viewer-search relative">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-neutral-500" />
<input type="search" x-model.debounce.300ms="searchQuery" placeholder="Find in logs"
aria-label="Find in logs"
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! text-neutral-800! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.05]! dark:text-white! dark:placeholder:text-neutral-500" />
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input type="search" x-ref="search" x-model.debounce.300ms="searchQuery" placeholder="Find in logs"
aria-label="Find in logs" :aria-keyshortcuts="findShortcutLabel === '⌘F' ? 'Meta+F' : 'Control+F'"
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint" />
<kbd x-cloak x-show="!searchQuery" x-text="findShortcutLabel" aria-hidden="true"
class="pointer-events-none absolute top-1/2 right-2 z-10 -translate-y-1/2 rounded border border-neutral-200 px-1 font-sans text-[10px] leading-4 text-neutral-400 dark:border-white/[0.08] dark:text-fg-faint"></kbd>
<button x-cloak x-show="searchQuery" x-on:click="searchQuery = ''" type="button"
class="absolute top-1/2 right-2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-800 dark:text-neutral-500 dark:hover:bg-white/[0.07] dark:hover:text-white"
class="absolute top-1/2 right-2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
aria-label="Clear search">
<x-reicon name="x" class="size-3" />
</button>
</div>
<div class="runtime-log-menus">
<x-table.dropdown panel-class="runtime-log-menu min-w-48! max-h-none!" role="menu">
<x-slot:trigger><button type="button" title="View Options"
class="button" aria-haspopup="menu" :aria-expanded="open">
<svg class="size-3.5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" />
</svg>
<span>View</span>
<x-reicon name="chevron-down" class="size-3 transition-transform" ::class="open ? 'rotate-180' : ''" />
</button></x-slot:trigger>
<div>
<p class="runtime-log-menu-label">Display</p>
<button type="button" class="listbox-option" role="menuitemcheckbox"
:aria-checked="showTimestamps" x-on:click="showTimestamps = !showTimestamps">
<span class="flex-1 text-left">Timestamps</span>
<span x-show="showTimestamps">✓</span>
</button>
@can('update', $application)
<button type="button" class="listbox-option" role="menuitemcheckbox"
aria-checked="{{ $is_debug_enabled ? 'true' : 'false' }}" wire:click="toggleDebug">
<span class="flex-1 text-left">Debug logs</span>
<x-loading compact wire:loading.flex wire:target="toggleDebug" class="hidden" aria-label="Loading debug logs..." />
@if ($is_debug_enabled)
<span wire:loading.remove wire:target="toggleDebug">✓</span>
@endif
</button>
@endcan
</div>
</x-table.dropdown>
<x-table.dropdown panel-class="runtime-log-menu min-w-52!" role="menu">
<x-slot:trigger><button type="button" title="Copy or Download Logs"
class="button" aria-haspopup="menu" :aria-expanded="open">
<svg class="size-3.5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
<span>Export</span>
<x-reicon name="chevron-down" class="size-3 transition-transform" ::class="open ? 'rotate-180' : ''" />
</button></x-slot:trigger>
<div x-data="{ downloadingAllLogs: false }">
<p class="px-2 py-1.5 text-xs text-neutral-500 dark:text-fg-dim">Copied and downloaded logs hide secrets.</p>
<button type="button" role="menuitem" class="listbox-option"
x-on:click="copyLogs(); open = false">
<span class="flex-1 text-left">Copy visible logs</span>
</button>
<button type="button" role="menuitem" class="listbox-option"
x-on:click="downloadLogs(); open = false">
<span class="flex-1 text-left">Download visible logs</span>
</button>
@can('update', $application)
<button type="button" role="menuitem" x-on:click="
downloadingAllLogs = true;
$wire.downloadAllLogs().then(logs => {
if (!logs) return;
const blob = new Blob([logs], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const timestamp = new Date().toISOString().slice(0,19).replace(/[T:]/g, '-');
a.download = 'deployment-' + deploymentId + '-all-logs-' + timestamp + '.txt';
a.click();
URL.revokeObjectURL(url);
Livewire.dispatch('success', ['All logs downloaded.']);
}).finally(() => {
downloadingAllLogs = false;
open = false;
});
"
:disabled="downloadingAllLogs"
:class="{ 'opacity-50 cursor-not-allowed': downloadingAllLogs }"
class="listbox-option">
<span x-show="!downloadingAllLogs" class="flex-1 text-left">Download full log (with debug)</span>
<span x-show="downloadingAllLogs" class="flex items-center gap-2">
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Downloading...
</span>
</button>
@endcan
</div>
</x-table.dropdown>
</div>
<div class="logs-viewer-meta">
<span x-show="searchQuery.trim()" x-text="matchCount + ' matches'"
class="text-xs text-neutral-500 whitespace-nowrap"></span>
@@ -1,9 +1,11 @@
<?php
use App\Enums\ApplicationDeploymentStatus;
use App\Livewire\Project\Application\Deployment\Show;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\Project;
@@ -12,6 +14,7 @@ use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -105,3 +108,84 @@ it('links deployment commit hashes to the source commit page', function () {
false,
);
});
it('swaps the logs viewer to another deployment of the same application only', function () {
$makeDeployment = fn (Application $application, string $uuid) => 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');
});