Improve logging view performance to prevent browser freezing (#7682)

This commit is contained in:
Andras Bacsai
2025-12-18 08:50:04 +01:00
committed by GitHub
4 changed files with 217 additions and 176 deletions

View File

@@ -5,17 +5,20 @@
logsLoaded: false,
fullscreen: false,
alwaysScroll: false,
intervalId: null,
rafId: null,
scrollDebounce: null,
colorLogs: localStorage.getItem('coolify-color-logs') === 'true',
searchQuery: '',
renderTrigger: 0,
matchCount: 0,
containerName: '{{ $container ?? "logs" }}',
makeFullscreen() {
this.fullscreen = !this.fullscreen;
if (this.fullscreen === false) {
this.alwaysScroll = false;
clearInterval(this.intervalId);
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
},
handleKeyDown(event) {
@@ -24,67 +27,72 @@
}
},
isScrolling: false,
scrollToBottom() {
const logsContainer = document.getElementById('logsContainer');
if (logsContainer) {
this.isScrolling = true;
logsContainer.scrollTop = logsContainer.scrollHeight;
setTimeout(() => { this.isScrolling = false; }, 50);
}
},
scheduleScroll() {
if (!this.alwaysScroll) return;
this.rafId = requestAnimationFrame(() => {
this.scrollToBottom();
if (this.alwaysScroll) {
setTimeout(() => this.scheduleScroll(), 250);
}
});
},
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);
this.scheduleScroll();
} else {
clearInterval(this.intervalId);
this.intervalId = null;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
},
handleScroll(event) {
// Skip if follow logs is disabled or this is a programmatic scroll
if (!this.alwaysScroll || this.isScrolling) return;
// 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;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
}, 150);
},
toggleColorLogs() {
this.colorLogs = !this.colorLogs;
localStorage.setItem('coolify-color-logs', this.colorLogs);
this.applyColorLogs();
},
getLogLevel(text) {
const lowerText = text.toLowerCase();
// Error detection (highest priority)
if (/\b(error|err|failed|failure|exception|fatal|panic|critical)\b/.test(lowerText)) {
return 'error';
}
// Warning detection
if (/\b(warn|warning|wrn|caution)\b/.test(lowerText)) {
return 'warning';
}
// Debug detection
if (/\b(debug|dbg|trace|verbose)\b/.test(lowerText)) {
return 'debug';
}
// Info detection
if (/\b(info|inf|notice)\b/.test(lowerText)) {
return 'info';
}
return null;
},
matchesSearch(line) {
if (!this.searchQuery.trim()) return true;
return line.toLowerCase().includes(this.searchQuery.toLowerCase());
applyColorLogs() {
const logs = document.getElementById('logs');
if (!logs) return;
const lines = logs.querySelectorAll('[data-log-line]');
lines.forEach(line => {
const content = (line.dataset.logContent || '').toLowerCase();
line.classList.remove('log-error', 'log-warning', 'log-debug', 'log-info');
if (!this.colorLogs) return;
if (/\b(error|err|failed|failure|exception|fatal|panic|critical)\b/.test(content)) {
line.classList.add('log-error');
} else if (/\b(warn|warning|wrn|caution)\b/.test(content)) {
line.classList.add('log-warning');
} else if (/\b(debug|dbg|trace|verbose)\b/.test(content)) {
line.classList.add('log-debug');
} else if (/\b(info|inf|notice)\b/.test(content)) {
line.classList.add('log-info');
}
});
},
hasActiveLogSelection() {
const selection = window.getSelection();
@@ -93,79 +101,62 @@
}
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) {
// Decode HTML entities, handling double-encoding with max iteration limit to prevent DoS
let decoded = text;
let prev = '';
let iterations = 0;
const maxIterations = 3; // Prevent DoS from deeply nested HTML entities
applySearch() {
const logs = document.getElementById('logs');
if (!logs) return;
const lines = logs.querySelectorAll('[data-log-line]');
const query = this.searchQuery.trim().toLowerCase();
let count = 0;
while (decoded !== prev && iterations < maxIterations) {
prev = decoded;
const doc = new DOMParser().parseFromString(decoded, 'text/html');
decoded = doc.documentElement.textContent;
iterations++;
}
return decoded;
},
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;
}
lines.forEach(line => {
const content = (line.dataset.logContent || '').toLowerCase();
const textSpan = line.querySelector('[data-line-text]');
const matches = !query || content.includes(query);
const decoded = this.decodeHtml(text);
el.textContent = '';
line.classList.toggle('hidden', !matches);
if (matches && query) count++;
if (!this.searchQuery.trim()) {
el.textContent = decoded;
return;
}
const query = this.searchQuery.toLowerCase();
const lowerText = decoded.toLowerCase();
let lastIndex = 0;
let index = lowerText.indexOf(query, lastIndex);
while (index !== -1) {
// Add text before match
if (index > lastIndex) {
el.appendChild(document.createTextNode(decoded.substring(lastIndex, index)));
// Update highlighting
if (textSpan) {
const originalText = textSpan.dataset.lineText || '';
if (!query) {
textSpan.textContent = originalText;
} else if (matches) {
this.highlightText(textSpan, originalText, query);
}
}
});
this.matchCount = query ? count : 0;
},
highlightText(el, text, query) {
// Skip if user has selection
if (this.hasActiveLogSelection()) return;
el.textContent = '';
const lowerText = text.toLowerCase();
let lastIndex = 0;
let index = lowerText.indexOf(query, lastIndex);
while (index !== -1) {
if (index > lastIndex) {
el.appendChild(document.createTextNode(text.substring(lastIndex, index)));
}
// Add highlighted match
const mark = document.createElement('span');
mark.className = 'log-highlight';
mark.textContent = decoded.substring(index, index + this.searchQuery.length);
mark.textContent = text.substring(index, index + query.length);
el.appendChild(mark);
lastIndex = index + this.searchQuery.length;
lastIndex = index + query.length;
index = lowerText.indexOf(query, lastIndex);
}
// Add remaining text
if (lastIndex < decoded.length) {
el.appendChild(document.createTextNode(decoded.substring(lastIndex)));
if (lastIndex < text.length) {
el.appendChild(document.createTextNode(text.substring(lastIndex)));
}
},
getMatchCount() {
if (!this.searchQuery.trim()) return 0;
const logs = document.getElementById('logs');
if (!logs) return 0;
const lines = logs.querySelectorAll('[data-log-line]');
let count = 0;
lines.forEach(line => {
if (line.textContent.toLowerCase().includes(this.searchQuery.toLowerCase())) {
count++;
}
});
return count;
},
downloadLogs() {
const logs = document.getElementById('logs');
if (!logs) return;
@@ -191,17 +182,23 @@
this.$wire.getLogs(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();
}
// Watch search query changes
this.$watch('searchQuery', () => {
this.applySearch();
});
// Re-render logs after Livewire updates
Livewire.hook('commit', ({ succeed }) => {
succeed(() => {
this.$nextTick(() => { this.renderTrigger++; });
});
// Apply colors after Livewire updates
Livewire.hook('morph.updated', ({ el }) => {
if (el.id === 'logs') {
this.$nextTick(() => {
this.applyColorLogs();
this.applySearch();
if (this.alwaysScroll) {
this.scrollToBottom();
}
});
}
});
}
}" @keydown.window="handleKeyDown($event)">
@@ -242,7 +239,7 @@
title="Number of Lines" {{ $streamLogs ? 'readonly' : '' }}
class="input input-sm w-32 pl-11 text-center dark:bg-coolgray-300" />
</form>
<span x-show="searchQuery.trim()" x-text="getMatchCount() + ' matches'"
<span x-show="searchQuery.trim()" x-text="matchCount + ' matches'"
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"></span>
</div>
<div class="flex items-center gap-2">
@@ -374,7 +371,7 @@
Showing last {{ number_format($maxDisplayLines) }} of {{ number_format($totalLines) }} lines
</div>
@endif
<div x-show="searchQuery.trim() && getMatchCount() === 0"
<div x-show="searchQuery.trim() && matchCount === 0"
class="text-gray-500 dark:text-gray-400 py-2">
No matches found.
</div>
@@ -398,23 +395,12 @@
// Format: 2025-Dec-04 09:44:58.198879
$timestamp = "{$year}-{$monthName}-{$day} {$time}.{$microseconds}";
}
@endphp
<div data-log-line data-log-content="{{ $line }}"
x-effect="renderTrigger; searchQuery; $el.classList.toggle('hidden', !matchesSearch($el.dataset.logContent))"
x-bind:class="{
'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-purple-500/10 dark:bg-purple-500/15': colorLogs && getLogLevel($el.dataset.logContent) === 'debug',
'bg-blue-500/10 dark:bg-blue-500/15': colorLogs && getLogLevel($el.dataset.logContent) === 'info',
}"
class="flex gap-2">
<div data-log-line data-log-content="{{ $line }}" class="flex gap-2 log-line">
@if ($timestamp && $showTimeStamps)
<span class="shrink-0 text-gray-500">{{ $timestamp }}</span>
@endif
<span data-line-text="{{ $logContent }}"
x-effect="renderTrigger; searchQuery; renderHighlightedLog($el, $el.dataset.lineText)"
class="whitespace-pre-wrap break-all"></span>
<span data-line-text="{{ $logContent }}" class="whitespace-pre-wrap break-all">{{ $logContent }}</span>
</div>
@endforeach
</div>