Fix deployment log view UX issues (#7636)
This commit is contained in:
commit
e0b2424b76
3 changed files with 96 additions and 39 deletions
|
|
@ -20,6 +20,8 @@ class Show extends Component
|
||||||
|
|
||||||
public bool $is_debug_enabled = false;
|
public bool $is_debug_enabled = false;
|
||||||
|
|
||||||
|
public bool $fullscreen = false;
|
||||||
|
|
||||||
private bool $deploymentFinishedDispatched = false;
|
private bool $deploymentFinishedDispatched = false;
|
||||||
|
|
||||||
public function getListeners()
|
public function getListeners()
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<livewire:project.shared.configuration-checker :resource="$application" />
|
<livewire:project.shared.configuration-checker :resource="$application" />
|
||||||
<livewire:project.application.heading :application="$application" />
|
<livewire:project.application.heading :application="$application" />
|
||||||
<div x-data="{
|
<div x-data="{
|
||||||
fullscreen: false,
|
fullscreen: @entangle('fullscreen'),
|
||||||
alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }},
|
alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }},
|
||||||
intervalId: null,
|
intervalId: null,
|
||||||
showTimestamps: true,
|
showTimestamps: true,
|
||||||
|
|
@ -34,6 +34,18 @@
|
||||||
if (!this.searchQuery.trim()) return true;
|
if (!this.searchQuery.trim()) return true;
|
||||||
return text.toLowerCase().includes(this.searchQuery.toLowerCase());
|
return text.toLowerCase().includes(this.searchQuery.toLowerCase());
|
||||||
},
|
},
|
||||||
|
hasActiveLogSelection() {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const logsContainer = document.getElementById('logs');
|
||||||
|
if (!logsContainer) return false;
|
||||||
|
|
||||||
|
// Check if selection is within the logs container
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
return logsContainer.contains(range.commonAncestorContainer);
|
||||||
|
},
|
||||||
decodeHtml(text) {
|
decodeHtml(text) {
|
||||||
// Decode HTML entities, handling double-encoding with max iteration limit to prevent DoS
|
// Decode HTML entities, handling double-encoding with max iteration limit to prevent DoS
|
||||||
let decoded = text;
|
let decoded = text;
|
||||||
|
|
@ -50,6 +62,12 @@
|
||||||
return decoded;
|
return decoded;
|
||||||
},
|
},
|
||||||
renderHighlightedLog(el, text) {
|
renderHighlightedLog(el, text) {
|
||||||
|
// Skip re-render if user has text selected in logs (preserves copy ability)
|
||||||
|
// But always render if the element is empty (initial render)
|
||||||
|
if (el.textContent && this.hasActiveLogSelection()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const decoded = this.decodeHtml(text);
|
const decoded = this.decodeHtml(text);
|
||||||
el.textContent = '';
|
el.textContent = '';
|
||||||
|
|
||||||
|
|
@ -129,6 +147,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
|
// Prevent Livewire from morphing logs container when text is selected
|
||||||
|
Livewire.hook('morph.updating', ({ el, component, toEl, skip }) => {
|
||||||
|
if (el.id === 'logs' && this.hasActiveLogSelection()) {
|
||||||
|
skip();
|
||||||
|
}
|
||||||
|
});
|
||||||
// Re-render logs after Livewire updates
|
// Re-render logs after Livewire updates
|
||||||
document.addEventListener('livewire:navigated', () => {
|
document.addEventListener('livewire:navigated', () => {
|
||||||
this.$nextTick(() => { this.renderTrigger++; });
|
this.$nextTick(() => { this.renderTrigger++; });
|
||||||
|
|
@ -158,28 +182,28 @@
|
||||||
}">
|
}">
|
||||||
<livewire:project.application.deployment-navbar
|
<livewire:project.application.deployment-navbar
|
||||||
:application_deployment_queue="$application_deployment_queue" />
|
:application_deployment_queue="$application_deployment_queue" />
|
||||||
@if (data_get($application_deployment_queue, 'status') === 'in_progress')
|
<div id="screen" :class="fullscreen ? 'fullscreen flex flex-col' : 'mt-4 relative'">
|
||||||
<div class="flex items-center gap-1 pt-2 ">Deployment is
|
|
||||||
<div class="dark:text-warning">
|
|
||||||
{{ Str::headline(data_get($this->application_deployment_queue, 'status')) }}.
|
|
||||||
</div>
|
|
||||||
<x-loading class="loading-ring" />
|
|
||||||
</div>
|
|
||||||
{{-- <div class="">Logs will be updated automatically.</div> --}}
|
|
||||||
@else
|
|
||||||
<div class="pt-2 ">Deployment is <span
|
|
||||||
class="dark:text-warning">{{ Str::headline(data_get($application_deployment_queue, 'status')) }}</span>.
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
<div id="screen" :class="fullscreen ? 'fullscreen flex flex-col' : 'relative'">
|
|
||||||
<div @if ($isKeepAliveOn) wire:poll.2000ms="polling" @endif
|
<div @if ($isKeepAliveOn) wire:poll.2000ms="polling" @endif
|
||||||
class="flex flex-col w-full bg-white dark:text-white dark:bg-coolgray-100 dark:border-coolgray-300"
|
class="flex flex-col w-full bg-white dark:text-white dark:bg-coolgray-100 dark:border-coolgray-300"
|
||||||
:class="fullscreen ? 'h-full' : 'mt-4 border border-dotted rounded-sm'">
|
:class="fullscreen ? 'h-full' : 'border border-dotted rounded-sm'">
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between gap-2 px-4 py-2 border-b dark:border-coolgray-300 border-neutral-200 shrink-0">
|
class="flex items-center justify-between gap-2 px-4 py-2 border-b dark:border-coolgray-300 border-neutral-200 shrink-0">
|
||||||
<span x-show="searchQuery.trim()" x-text="getMatchCount() + ' matches'"
|
<div class="flex items-center gap-3">
|
||||||
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"></span>
|
@if (data_get($application_deployment_queue, 'status') === 'in_progress')
|
||||||
<span x-show="!searchQuery.trim()"></span>
|
<div class="flex items-center gap-1">
|
||||||
|
<span>Deployment is</span>
|
||||||
|
<span class="dark:text-warning">In Progress</span>
|
||||||
|
<x-loading class="loading-ring loading-xs" />
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span>Deployment is</span>
|
||||||
|
<span class="dark:text-warning">{{ Str::headline(data_get($application_deployment_queue, 'status')) }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<span x-show="searchQuery.trim()" x-text="getMatchCount() + ' matches'"
|
||||||
|
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"></span>
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<svg class="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
|
<svg class="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
|
||||||
|
|
@ -267,7 +291,7 @@ class="text-gray-500 dark:text-gray-400 py-2">
|
||||||
$searchableContent = $line['timestamp'] . ' ' . $lineContent;
|
$searchableContent = $line['timestamp'] . ' ' . $lineContent;
|
||||||
@endphp
|
@endphp
|
||||||
<div data-log-line data-log-content="{{ htmlspecialchars($searchableContent) }}"
|
<div data-log-line data-log-content="{{ htmlspecialchars($searchableContent) }}"
|
||||||
x-bind:class="{ 'hidden': !matchesSearch($el.dataset.logContent) }" @class([
|
x-effect="renderTrigger; searchQuery; $el.classList.toggle('hidden', !matchesSearch($el.dataset.logContent))" @class([
|
||||||
'mt-2' => isset($line['command']) && $line['command'],
|
'mt-2' => isset($line['command']) && $line['command'],
|
||||||
'flex gap-2',
|
'flex gap-2',
|
||||||
])>
|
])>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
scrollDebounce: null,
|
scrollDebounce: null,
|
||||||
colorLogs: localStorage.getItem('coolify-color-logs') === 'true',
|
colorLogs: localStorage.getItem('coolify-color-logs') === 'true',
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
|
renderTrigger: 0,
|
||||||
containerName: '{{ $container ?? "logs" }}',
|
containerName: '{{ $container ?? "logs" }}',
|
||||||
makeFullscreen() {
|
makeFullscreen() {
|
||||||
this.fullscreen = !this.fullscreen;
|
this.fullscreen = !this.fullscreen;
|
||||||
|
|
@ -80,6 +81,18 @@
|
||||||
if (!this.searchQuery.trim()) return true;
|
if (!this.searchQuery.trim()) return true;
|
||||||
return line.toLowerCase().includes(this.searchQuery.toLowerCase());
|
return line.toLowerCase().includes(this.searchQuery.toLowerCase());
|
||||||
},
|
},
|
||||||
|
hasActiveLogSelection() {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const logsContainer = document.getElementById('logs');
|
||||||
|
if (!logsContainer) return false;
|
||||||
|
|
||||||
|
// Check if selection is within the logs container
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
return logsContainer.contains(range.commonAncestorContainer);
|
||||||
|
},
|
||||||
decodeHtml(text) {
|
decodeHtml(text) {
|
||||||
// Decode HTML entities, handling double-encoding with max iteration limit to prevent DoS
|
// Decode HTML entities, handling double-encoding with max iteration limit to prevent DoS
|
||||||
let decoded = text;
|
let decoded = text;
|
||||||
|
|
@ -96,6 +109,12 @@
|
||||||
return decoded;
|
return decoded;
|
||||||
},
|
},
|
||||||
renderHighlightedLog(el, text) {
|
renderHighlightedLog(el, text) {
|
||||||
|
// Skip re-render if user has text selected in logs (preserves copy ability)
|
||||||
|
// But always render if the element is empty (initial render)
|
||||||
|
if (el.textContent && this.hasActiveLogSelection()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const decoded = this.decodeHtml(text);
|
const decoded = this.decodeHtml(text);
|
||||||
el.textContent = '';
|
el.textContent = '';
|
||||||
|
|
||||||
|
|
@ -167,6 +186,18 @@
|
||||||
this.$wire.getLogs(true);
|
this.$wire.getLogs(true);
|
||||||
this.logsLoaded = true;
|
this.logsLoaded = true;
|
||||||
}
|
}
|
||||||
|
// Prevent Livewire from morphing logs container when text is selected
|
||||||
|
Livewire.hook('morph.updating', ({ el, component, toEl, skip }) => {
|
||||||
|
if (el.id === 'logs' && this.hasActiveLogSelection()) {
|
||||||
|
skip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Re-render logs after Livewire updates
|
||||||
|
Livewire.hook('commit', ({ succeed }) => {
|
||||||
|
succeed(() => {
|
||||||
|
this.$nextTick(() => { this.renderTrigger++; });
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}">
|
}">
|
||||||
@if ($collapsible)
|
@if ($collapsible)
|
||||||
|
|
@ -235,6 +266,23 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
|
||||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button wire:click="toggleStreamLogs"
|
||||||
|
title="{{ $streamLogs ? 'Stop Streaming' : 'Stream Logs' }}"
|
||||||
|
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 {{ $streamLogs ? '!text-warning' : '' }}">
|
||||||
|
@if ($streamLogs)
|
||||||
|
{{-- Pause icon --}}
|
||||||
|
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="currentColor">
|
||||||
|
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||||
|
</svg>
|
||||||
|
@else
|
||||||
|
{{-- Play icon --}}
|
||||||
|
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="currentColor">
|
||||||
|
<path d="M8 5v14l11-7L8 5z" />
|
||||||
|
</svg>
|
||||||
|
@endif
|
||||||
|
</button>
|
||||||
<button x-on:click="downloadLogs()" title="Download Logs"
|
<button x-on:click="downloadLogs()" title="Download Logs"
|
||||||
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||||
|
|
@ -260,23 +308,6 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
|
||||||
d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" />
|
d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button wire:click="toggleStreamLogs"
|
|
||||||
title="{{ $streamLogs ? 'Stop Streaming' : 'Stream Logs' }}"
|
|
||||||
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 {{ $streamLogs ? '!text-warning' : '' }}">
|
|
||||||
@if ($streamLogs)
|
|
||||||
{{-- Pause icon --}}
|
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="currentColor">
|
|
||||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
|
||||||
</svg>
|
|
||||||
@else
|
|
||||||
{{-- Play icon --}}
|
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="currentColor">
|
|
||||||
<path d="M8 5v14l11-7L8 5z" />
|
|
||||||
</svg>
|
|
||||||
@endif
|
|
||||||
</button>
|
|
||||||
<button title="Follow Logs" :class="alwaysScroll ? '!text-warning' : ''"
|
<button title="Follow Logs" :class="alwaysScroll ? '!text-warning' : ''"
|
||||||
x-on:click="toggleScroll"
|
x-on:click="toggleScroll"
|
||||||
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
|
|
@ -350,8 +381,8 @@ class="text-gray-500 dark:text-gray-400 py-2">
|
||||||
|
|
||||||
@endphp
|
@endphp
|
||||||
<div data-log-line data-log-content="{{ $line }}"
|
<div data-log-line data-log-content="{{ $line }}"
|
||||||
|
x-effect="renderTrigger; searchQuery; $el.classList.toggle('hidden', !matchesSearch($el.dataset.logContent))"
|
||||||
x-bind:class="{
|
x-bind:class="{
|
||||||
'hidden': !matchesSearch($el.dataset.logContent),
|
|
||||||
'bg-red-500/10 dark:bg-red-500/15': colorLogs && getLogLevel($el.dataset.logContent) === 'error',
|
'bg-red-500/10 dark:bg-red-500/15': colorLogs && getLogLevel($el.dataset.logContent) === 'error',
|
||||||
'bg-yellow-500/10 dark:bg-yellow-500/15': colorLogs && getLogLevel($el.dataset.logContent) === 'warning',
|
'bg-yellow-500/10 dark:bg-yellow-500/15': colorLogs && getLogLevel($el.dataset.logContent) === 'warning',
|
||||||
'bg-purple-500/10 dark:bg-purple-500/15': colorLogs && getLogLevel($el.dataset.logContent) === 'debug',
|
'bg-purple-500/10 dark:bg-purple-500/15': colorLogs && getLogLevel($el.dataset.logContent) === 'debug',
|
||||||
|
|
@ -362,7 +393,7 @@ class="flex gap-2">
|
||||||
<span class="shrink-0 text-gray-500">{{ $timestamp }}</span>
|
<span class="shrink-0 text-gray-500">{{ $timestamp }}</span>
|
||||||
@endif
|
@endif
|
||||||
<span data-line-text="{{ $logContent }}"
|
<span data-line-text="{{ $logContent }}"
|
||||||
x-effect="searchQuery; renderHighlightedLog($el, $el.dataset.lineText)"
|
x-effect="renderTrigger; searchQuery; renderHighlightedLog($el, $el.dataset.lineText)"
|
||||||
class="whitespace-pre-wrap break-all"></span>
|
class="whitespace-pre-wrap break-all"></span>
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue