mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
co-authored by
Claude Opus 5.5
parent
3bfeb14b7d
commit
95d796e922
@@ -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
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user