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:
Andras Bacsai 2025-12-11 09:14:27 +01:00
parent 0e6a2fc15d
commit d9762e0310
3 changed files with 64 additions and 48 deletions

View file

@ -22,10 +22,7 @@ class Show extends Component
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
'refreshQueue',
];
}
@ -91,10 +88,14 @@ private function isKeepAliveOn()
public function polling()
{
$this->dispatch('deploymentFinished');
$this->application_deployment_queue->refresh();
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
$this->isKeepAliveOn();
// Dispatch event when deployment finishes to stop auto-scroll
if (! $this->isKeepAliveOn) {
$this->dispatch('deploymentFinished');
}
}
public function getLogLinesProperty()

View file

@ -15,21 +15,14 @@
deploymentId: '{{ $application_deployment_queue->deployment_uuid ?? 'deployment' }}',
makeFullscreen() {
this.fullscreen = !this.fullscreen;
if (this.fullscreen === false) {
this.alwaysScroll = false;
clearInterval(this.intervalId);
}
},
isScrolling: false,
toggleScroll() {
this.alwaysScroll = !this.alwaysScroll;
if (this.alwaysScroll) {
this.intervalId = setInterval(() => {
const logsContainer = document.getElementById('logsContainer');
if (logsContainer) {
this.isScrolling = true;
logsContainer.scrollTop = logsContainer.scrollHeight;
setTimeout(() => { this.isScrolling = false; }, 50);
}
}, 100);
} else {
@ -37,17 +30,6 @@
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) {
if (!this.searchQuery.trim()) return true;
return text.toLowerCase().includes(this.searchQuery.toLowerCase());
@ -134,6 +116,18 @@
a.click();
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() {
// Re-render logs after Livewire updates
document.addEventListener('livewire:navigated', () => {
@ -144,14 +138,19 @@
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
if (this.alwaysScroll) {
this.intervalId = setInterval(() => {
const logsContainer = document.getElementById('logsContainer');
if (logsContainer) {
this.isScrolling = true;
logsContainer.scrollTop = logsContainer.scrollHeight;
setTimeout(() => { this.isScrolling = false; }, 50);
}
}, 100);
}
@ -254,7 +253,7 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
</button>
</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="fullscreen ? 'flex-1' : 'max-h-[40rem]'">
<div id="logs" class="flex flex-col font-mono">

View file

@ -6,9 +6,10 @@
fullscreen: false,
alwaysScroll: false,
intervalId: null,
scrollDebounce: null,
searchTimeout: null,
colorLogs: localStorage.getItem('coolify-color-logs') === 'true',
searchQuery: '',
renderTrigger: 0,
containerName: '{{ $container ?? "logs" }}',
makeFullscreen() {
this.fullscreen = !this.fullscreen;
@ -35,15 +36,22 @@
}
},
handleScroll(event) {
// Skip if follow logs is disabled or this is a programmatic scroll
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;
}
// Debounce scroll handling to avoid false positives from DOM mutations
// when Livewire re-renders and adds new log lines
clearTimeout(this.scrollDebounce);
this.scrollDebounce = setTimeout(() => {
const el = event.target;
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() {
this.colorLogs = !this.colorLogs;
@ -73,6 +81,12 @@
if (!this.searchQuery.trim()) return true;
return line.toLowerCase().includes(this.searchQuery.toLowerCase());
},
debouncedSearch(query) {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.searchQuery = query;
}, 300);
},
decodeHtml(text) {
// Decode HTML entities, handling double-encoding with max iteration limit to prevent DoS
let decoded = text;
@ -160,12 +174,6 @@
this.$wire.getLogs(true);
this.logsLoaded = true;
}
// Re-render logs after Livewire updates
Livewire.hook('commit', ({ succeed }) => {
succeed(() => {
this.$nextTick(() => { this.renderTrigger++; });
});
});
}
}">
@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"
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>
<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" />
<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">
@ -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="fullscreen ? 'flex-1' : 'max-h-[40rem]'">
@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">
@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"
class="text-gray-500 dark:text-gray-400 py-2">
No matches found.
</div>
@foreach (explode("\n", $outputs) as $line)
@foreach ($displayLines as $line)
@php
// Skip empty lines
if (trim($line) === '') {
continue;
}
// Parse timestamp from log line (ISO 8601 format: 2025-12-04T11:48:39.136764033Z)
$timestamp = '';
$logContent = $line;
@ -353,7 +369,7 @@ class="flex gap-2">
<span class="shrink-0 text-gray-500">{{ $timestamp }}</span>
@endif
<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>
</div>
@endforeach