Fix deployment log follow feature stopping mid-deployment
Removed auto-disable behaviors that caused follow logs to stop unexpectedly: - Removed scroll detection that disabled following when user scrolled >50px from bottom - Removed fullscreen exit handler that disabled following - Removed ServiceChecked event listener that caused unnecessary flickers Follow logs now only stops when: - User explicitly clicks the Follow Logs button - Deployment finishes (auto-scrolls to end first, then disables after 500ms delay) Also improved get-logs component with memory optimizations: - Limited display to last 2000 lines to prevent memory exhaustion - Added debounced search (300ms) and scroll handling (150ms) - Optimized DOM rendering 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
0e6a2fc15d
commit
d9762e0310
3 changed files with 64 additions and 48 deletions
|
|
@ -22,10 +22,7 @@ class Show extends Component
|
||||||
|
|
||||||
public function getListeners()
|
public function getListeners()
|
||||||
{
|
{
|
||||||
$teamId = auth()->user()->currentTeam()->id;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
|
|
||||||
'refreshQueue',
|
'refreshQueue',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -91,10 +88,14 @@ private function isKeepAliveOn()
|
||||||
|
|
||||||
public function polling()
|
public function polling()
|
||||||
{
|
{
|
||||||
$this->dispatch('deploymentFinished');
|
|
||||||
$this->application_deployment_queue->refresh();
|
$this->application_deployment_queue->refresh();
|
||||||
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
|
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
|
||||||
$this->isKeepAliveOn();
|
$this->isKeepAliveOn();
|
||||||
|
|
||||||
|
// Dispatch event when deployment finishes to stop auto-scroll
|
||||||
|
if (! $this->isKeepAliveOn) {
|
||||||
|
$this->dispatch('deploymentFinished');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getLogLinesProperty()
|
public function getLogLinesProperty()
|
||||||
|
|
|
||||||
|
|
@ -15,21 +15,14 @@
|
||||||
deploymentId: '{{ $application_deployment_queue->deployment_uuid ?? 'deployment' }}',
|
deploymentId: '{{ $application_deployment_queue->deployment_uuid ?? 'deployment' }}',
|
||||||
makeFullscreen() {
|
makeFullscreen() {
|
||||||
this.fullscreen = !this.fullscreen;
|
this.fullscreen = !this.fullscreen;
|
||||||
if (this.fullscreen === false) {
|
|
||||||
this.alwaysScroll = false;
|
|
||||||
clearInterval(this.intervalId);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
isScrolling: false,
|
|
||||||
toggleScroll() {
|
toggleScroll() {
|
||||||
this.alwaysScroll = !this.alwaysScroll;
|
this.alwaysScroll = !this.alwaysScroll;
|
||||||
if (this.alwaysScroll) {
|
if (this.alwaysScroll) {
|
||||||
this.intervalId = setInterval(() => {
|
this.intervalId = setInterval(() => {
|
||||||
const logsContainer = document.getElementById('logsContainer');
|
const logsContainer = document.getElementById('logsContainer');
|
||||||
if (logsContainer) {
|
if (logsContainer) {
|
||||||
this.isScrolling = true;
|
|
||||||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||||||
setTimeout(() => { this.isScrolling = false; }, 50);
|
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -37,17 +30,6 @@
|
||||||
this.intervalId = null;
|
this.intervalId = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
handleScroll(event) {
|
|
||||||
if (!this.alwaysScroll || this.isScrolling) return;
|
|
||||||
const el = event.target;
|
|
||||||
// Check if user scrolled away from the bottom
|
|
||||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
||||||
if (distanceFromBottom > 50) {
|
|
||||||
this.alwaysScroll = false;
|
|
||||||
clearInterval(this.intervalId);
|
|
||||||
this.intervalId = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
matchesSearch(text) {
|
matchesSearch(text) {
|
||||||
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());
|
||||||
|
|
@ -134,6 +116,18 @@
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
},
|
},
|
||||||
|
stopScroll() {
|
||||||
|
// Scroll to the end one final time before disabling
|
||||||
|
const logsContainer = document.getElementById('logsContainer');
|
||||||
|
if (logsContainer) {
|
||||||
|
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
this.alwaysScroll = false;
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
init() {
|
init() {
|
||||||
// Re-render logs after Livewire updates
|
// Re-render logs after Livewire updates
|
||||||
document.addEventListener('livewire:navigated', () => {
|
document.addEventListener('livewire:navigated', () => {
|
||||||
|
|
@ -144,14 +138,19 @@
|
||||||
this.$nextTick(() => { this.renderTrigger++; });
|
this.$nextTick(() => { this.renderTrigger++; });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// Stop auto-scroll when deployment finishes
|
||||||
|
Livewire.on('deploymentFinished', () => {
|
||||||
|
// Wait for DOM to update with final logs before scrolling to end
|
||||||
|
setTimeout(() => {
|
||||||
|
this.stopScroll();
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
// Start auto-scroll if deployment is in progress
|
// Start auto-scroll if deployment is in progress
|
||||||
if (this.alwaysScroll) {
|
if (this.alwaysScroll) {
|
||||||
this.intervalId = setInterval(() => {
|
this.intervalId = setInterval(() => {
|
||||||
const logsContainer = document.getElementById('logsContainer');
|
const logsContainer = document.getElementById('logsContainer');
|
||||||
if (logsContainer) {
|
if (logsContainer) {
|
||||||
this.isScrolling = true;
|
|
||||||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||||||
setTimeout(() => { this.isScrolling = false; }, 50);
|
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
@ -254,7 +253,7 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="logsContainer" @scroll="handleScroll"
|
<div id="logsContainer"
|
||||||
class="flex flex-col overflow-y-auto p-2 px-4 min-h-4 scrollbar"
|
class="flex flex-col overflow-y-auto p-2 px-4 min-h-4 scrollbar"
|
||||||
:class="fullscreen ? 'flex-1' : 'max-h-[40rem]'">
|
:class="fullscreen ? 'flex-1' : 'max-h-[40rem]'">
|
||||||
<div id="logs" class="flex flex-col font-mono">
|
<div id="logs" class="flex flex-col font-mono">
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,10 @@
|
||||||
fullscreen: false,
|
fullscreen: false,
|
||||||
alwaysScroll: false,
|
alwaysScroll: false,
|
||||||
intervalId: null,
|
intervalId: null,
|
||||||
|
scrollDebounce: null,
|
||||||
|
searchTimeout: 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;
|
||||||
|
|
@ -35,15 +36,22 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
handleScroll(event) {
|
handleScroll(event) {
|
||||||
|
// Skip if follow logs is disabled or this is a programmatic scroll
|
||||||
if (!this.alwaysScroll || this.isScrolling) return;
|
if (!this.alwaysScroll || this.isScrolling) return;
|
||||||
const el = event.target;
|
|
||||||
// Check if user scrolled away from the bottom
|
// Debounce scroll handling to avoid false positives from DOM mutations
|
||||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
// when Livewire re-renders and adds new log lines
|
||||||
if (distanceFromBottom > 50) {
|
clearTimeout(this.scrollDebounce);
|
||||||
this.alwaysScroll = false;
|
this.scrollDebounce = setTimeout(() => {
|
||||||
clearInterval(this.intervalId);
|
const el = event.target;
|
||||||
this.intervalId = null;
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
}
|
// Use larger threshold (100px) to avoid accidental disables
|
||||||
|
if (distanceFromBottom > 100) {
|
||||||
|
this.alwaysScroll = false;
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
},
|
},
|
||||||
toggleColorLogs() {
|
toggleColorLogs() {
|
||||||
this.colorLogs = !this.colorLogs;
|
this.colorLogs = !this.colorLogs;
|
||||||
|
|
@ -73,6 +81,12 @@
|
||||||
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());
|
||||||
},
|
},
|
||||||
|
debouncedSearch(query) {
|
||||||
|
clearTimeout(this.searchTimeout);
|
||||||
|
this.searchTimeout = setTimeout(() => {
|
||||||
|
this.searchQuery = query;
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
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;
|
||||||
|
|
@ -160,12 +174,6 @@
|
||||||
this.$wire.getLogs(true);
|
this.$wire.getLogs(true);
|
||||||
this.logsLoaded = true;
|
this.logsLoaded = true;
|
||||||
}
|
}
|
||||||
// Re-render logs after Livewire updates
|
|
||||||
Livewire.hook('commit', ({ succeed }) => {
|
|
||||||
succeed(() => {
|
|
||||||
this.$nextTick(() => { this.renderTrigger++; });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}">
|
}">
|
||||||
@if ($collapsible)
|
@if ($collapsible)
|
||||||
|
|
@ -216,7 +224,7 @@ class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"></span>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||||
</svg>
|
</svg>
|
||||||
<input type="text" x-model="searchQuery" placeholder="Find in logs"
|
<input type="text" x-on:input="debouncedSearch($event.target.value)" :value="searchQuery" placeholder="Find in logs"
|
||||||
class="input input-sm w-48 pl-8 pr-8 dark:bg-coolgray-300" />
|
class="input input-sm w-48 pl-8 pr-8 dark:bg-coolgray-300" />
|
||||||
<button x-show="searchQuery" x-on:click="searchQuery = ''" type="button"
|
<button x-show="searchQuery" x-on:click="searchQuery = ''" type="button"
|
||||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||||
|
|
@ -308,18 +316,26 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
|
||||||
class="flex overflow-y-auto overflow-x-hidden flex-col px-4 py-2 w-full min-w-0 scrollbar"
|
class="flex overflow-y-auto overflow-x-hidden flex-col px-4 py-2 w-full min-w-0 scrollbar"
|
||||||
:class="fullscreen ? 'flex-1' : 'max-h-[40rem]'">
|
:class="fullscreen ? 'flex-1' : 'max-h-[40rem]'">
|
||||||
@if ($outputs)
|
@if ($outputs)
|
||||||
|
@php
|
||||||
|
// Limit rendered lines to prevent memory exhaustion
|
||||||
|
$maxDisplayLines = 2000;
|
||||||
|
$allLines = collect(explode("\n", $outputs))->filter(fn($line) => trim($line) !== '');
|
||||||
|
$totalLines = $allLines->count();
|
||||||
|
$hasMoreLines = $totalLines > $maxDisplayLines;
|
||||||
|
$displayLines = $hasMoreLines ? $allLines->slice(-$maxDisplayLines)->values() : $allLines;
|
||||||
|
@endphp
|
||||||
<div id="logs" class="font-mono max-w-full cursor-default">
|
<div id="logs" class="font-mono max-w-full cursor-default">
|
||||||
|
@if ($hasMoreLines)
|
||||||
|
<div class="text-center py-2 text-gray-500 dark:text-gray-400 text-sm border-b dark:border-coolgray-300 mb-2">
|
||||||
|
Showing last {{ number_format($maxDisplayLines) }} of {{ number_format($totalLines) }} lines
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
<div x-show="searchQuery.trim() && getMatchCount() === 0"
|
<div x-show="searchQuery.trim() && getMatchCount() === 0"
|
||||||
class="text-gray-500 dark:text-gray-400 py-2">
|
class="text-gray-500 dark:text-gray-400 py-2">
|
||||||
No matches found.
|
No matches found.
|
||||||
</div>
|
</div>
|
||||||
@foreach (explode("\n", $outputs) as $line)
|
@foreach ($displayLines as $line)
|
||||||
@php
|
@php
|
||||||
// Skip empty lines
|
|
||||||
if (trim($line) === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse timestamp from log line (ISO 8601 format: 2025-12-04T11:48:39.136764033Z)
|
// Parse timestamp from log line (ISO 8601 format: 2025-12-04T11:48:39.136764033Z)
|
||||||
$timestamp = '';
|
$timestamp = '';
|
||||||
$logContent = $line;
|
$logContent = $line;
|
||||||
|
|
@ -353,7 +369,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="renderTrigger; searchQuery; renderHighlightedLog($el, $el.dataset.lineText)"
|
x-effect="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