Improve logging view performance to prevent browser freezing

- Use CSS content-visibility: auto for lazy rendering of off-screen log lines
- Replace setInterval auto-scroll with requestAnimationFrame for smoother scrolling
- Cache HTML entity decoding to avoid repeated DOMParser calls (up to 5000 entries)
- Cache match count calculations to prevent repeated DOM queries
- Debounce search input (300ms) in deployment logs view
- Debounce Livewire render updates (100ms) to batch rapid changes
- Add log-line utility class with content-visibility optimization
- Add log-highlight utility class for search result highlighting

These changes address browser freezing when viewing deployment logs with
3500+ lines (GitHub issue #7668). The content-visibility CSS property lets
the browser skip rendering of off-screen content, significantly reducing
initial render time and memory usage.

Fixes #7668
This commit is contained in:
Claude
2025-12-18 06:14:42 +00:00
parent b18d9a254b
commit 40b1b1319f
3 changed files with 147 additions and 67 deletions

View File

@@ -292,3 +292,14 @@
@utility xterm { @utility xterm {
@apply p-2; @apply p-2;
} }
/* Log line optimization - uses content-visibility for lazy rendering of off-screen log lines */
@utility log-line {
content-visibility: auto;
contain-intrinsic-size: auto 1.5em;
}
/* Search highlight styling for logs */
@utility log-highlight {
@apply bg-warning/40 dark:bg-warning/30 rounded-sm px-0.5;
}

View File

@@ -8,26 +8,44 @@
<div x-data="{ <div x-data="{
fullscreen: @entangle('fullscreen'), fullscreen: @entangle('fullscreen'),
alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }}, alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }},
intervalId: null, rafId: null,
showTimestamps: true, showTimestamps: true,
searchQuery: '', searchQuery: '',
renderTrigger: 0, renderTrigger: 0,
deploymentId: '{{ $application_deployment_queue->deployment_uuid ?? 'deployment' }}', deploymentId: '{{ $application_deployment_queue->deployment_uuid ?? 'deployment' }}',
// Cache for decoded HTML to avoid repeated DOMParser calls
decodeCache: new Map(),
// Cache for match count to avoid repeated DOM queries
matchCountCache: null,
lastSearchQuery: '',
makeFullscreen() { makeFullscreen() {
this.fullscreen = !this.fullscreen; this.fullscreen = !this.fullscreen;
}, },
scrollToBottom() {
const logsContainer = document.getElementById('logsContainer');
if (logsContainer) {
logsContainer.scrollTop = logsContainer.scrollHeight;
}
},
scheduleScroll() {
if (!this.alwaysScroll) return;
this.rafId = requestAnimationFrame(() => {
this.scrollToBottom();
// Schedule next scroll after a reasonable delay (250ms instead of 100ms)
if (this.alwaysScroll) {
setTimeout(() => this.scheduleScroll(), 250);
}
});
},
toggleScroll() { toggleScroll() {
this.alwaysScroll = !this.alwaysScroll; this.alwaysScroll = !this.alwaysScroll;
if (this.alwaysScroll) { if (this.alwaysScroll) {
this.intervalId = setInterval(() => { this.scheduleScroll();
const logsContainer = document.getElementById('logsContainer');
if (logsContainer) {
logsContainer.scrollTop = logsContainer.scrollHeight;
}
}, 100);
} else { } else {
clearInterval(this.intervalId); if (this.rafId) {
this.intervalId = null; cancelAnimationFrame(this.rafId);
this.rafId = null;
}
} }
}, },
matchesSearch(text) { matchesSearch(text) {
@@ -41,17 +59,19 @@
} }
const logsContainer = document.getElementById('logs'); const logsContainer = document.getElementById('logs');
if (!logsContainer) return false; if (!logsContainer) return false;
// Check if selection is within the logs container
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
return logsContainer.contains(range.commonAncestorContainer); return logsContainer.contains(range.commonAncestorContainer);
}, },
decodeHtml(text) { decodeHtml(text) {
// Decode HTML entities, handling double-encoding with max iteration limit to prevent DoS // Return cached result if available
if (this.decodeCache.has(text)) {
return this.decodeCache.get(text);
}
// Decode HTML entities with max iteration limit
let decoded = text; let decoded = text;
let prev = ''; let prev = '';
let iterations = 0; let iterations = 0;
const maxIterations = 3; // Prevent DoS from deeply nested HTML entities const maxIterations = 3;
while (decoded !== prev && iterations < maxIterations) { while (decoded !== prev && iterations < maxIterations) {
prev = decoded; prev = decoded;
@@ -59,11 +79,17 @@
decoded = doc.documentElement.textContent; decoded = doc.documentElement.textContent;
iterations++; iterations++;
} }
// Cache the result (limit cache size to prevent memory bloat)
if (this.decodeCache.size > 5000) {
// Clear oldest entries when cache gets too large
const firstKey = this.decodeCache.keys().next().value;
this.decodeCache.delete(firstKey);
}
this.decodeCache.set(text, decoded);
return decoded; return decoded;
}, },
renderHighlightedLog(el, text) { renderHighlightedLog(el, text) {
// Skip re-render if user has text selected in logs (preserves copy ability) // Skip re-render if user has text selected in logs
// But always render if the element is empty (initial render)
if (el.textContent && this.hasActiveLogSelection()) { if (el.textContent && this.hasActiveLogSelection()) {
return; return;
} }
@@ -82,11 +108,9 @@
let index = lowerText.indexOf(query, lastIndex); let index = lowerText.indexOf(query, lastIndex);
while (index !== -1) { while (index !== -1) {
// Add text before match
if (index > lastIndex) { if (index > lastIndex) {
el.appendChild(document.createTextNode(decoded.substring(lastIndex, index))); el.appendChild(document.createTextNode(decoded.substring(lastIndex, index)));
} }
// Add highlighted match
const mark = document.createElement('span'); const mark = document.createElement('span');
mark.className = 'log-highlight'; mark.className = 'log-highlight';
mark.textContent = decoded.substring(index, index + this.searchQuery.length); mark.textContent = decoded.substring(index, index + this.searchQuery.length);
@@ -96,22 +120,28 @@
index = lowerText.indexOf(query, lastIndex); index = lowerText.indexOf(query, lastIndex);
} }
// Add remaining text
if (lastIndex < decoded.length) { if (lastIndex < decoded.length) {
el.appendChild(document.createTextNode(decoded.substring(lastIndex))); el.appendChild(document.createTextNode(decoded.substring(lastIndex)));
} }
}, },
getMatchCount() { getMatchCount() {
if (!this.searchQuery.trim()) return 0; if (!this.searchQuery.trim()) return 0;
// Return cached count if search query hasn't changed
if (this.lastSearchQuery === this.searchQuery && this.matchCountCache !== null) {
return this.matchCountCache;
}
const logs = document.getElementById('logs'); const logs = document.getElementById('logs');
if (!logs) return 0; if (!logs) return 0;
const lines = logs.querySelectorAll('[data-log-line]'); const lines = logs.querySelectorAll('[data-log-line]');
let count = 0; let count = 0;
const query = this.searchQuery.toLowerCase();
lines.forEach(line => { lines.forEach(line => {
if (line.dataset.logContent && line.dataset.logContent.toLowerCase().includes(this.searchQuery.toLowerCase())) { if (line.dataset.logContent && line.dataset.logContent.toLowerCase().includes(query)) {
count++; count++;
} }
}); });
this.matchCountCache = count;
this.lastSearchQuery = this.searchQuery;
return count; return count;
}, },
downloadLogs() { downloadLogs() {
@@ -135,15 +165,11 @@
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, },
stopScroll() { stopScroll() {
// Scroll to the end one final time before disabling this.scrollToBottom();
const logsContainer = document.getElementById('logsContainer');
if (logsContainer) {
logsContainer.scrollTop = logsContainer.scrollHeight;
}
this.alwaysScroll = false; this.alwaysScroll = false;
if (this.intervalId) { if (this.rafId) {
clearInterval(this.intervalId); cancelAnimationFrame(this.rafId);
this.intervalId = null; this.rafId = null;
} }
}, },
init() { init() {
@@ -153,30 +179,32 @@
skip(); skip();
} }
}); });
// Re-render logs after Livewire updates // Re-render logs after Livewire updates (debounced)
let renderTimeout = null;
const debouncedRender = () => {
clearTimeout(renderTimeout);
renderTimeout = setTimeout(() => {
this.matchCountCache = null; // Invalidate match cache on new content
this.renderTrigger++;
}, 100);
};
document.addEventListener('livewire:navigated', () => { document.addEventListener('livewire:navigated', () => {
this.$nextTick(() => { this.renderTrigger++; }); this.$nextTick(debouncedRender);
}); });
Livewire.hook('commit', ({ succeed }) => { Livewire.hook('commit', ({ succeed }) => {
succeed(() => { succeed(() => {
this.$nextTick(() => { this.renderTrigger++; }); this.$nextTick(debouncedRender);
}); });
}); });
// Stop auto-scroll when deployment finishes // Stop auto-scroll when deployment finishes
Livewire.on('deploymentFinished', () => { Livewire.on('deploymentFinished', () => {
// Wait for DOM to update with final logs before scrolling to end
setTimeout(() => { setTimeout(() => {
this.stopScroll(); this.stopScroll();
}, 500); }, 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.scheduleScroll();
const logsContainer = document.getElementById('logsContainer');
if (logsContainer) {
logsContainer.scrollTop = logsContainer.scrollHeight;
}
}, 100);
} }
} }
}"> }">
@@ -212,7 +240,7 @@
<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-model.debounce.300ms="searchQuery" placeholder="Find in logs"
class="input input-sm w-48 pl-8 pr-8 dark:bg-coolgray-200" /> class="input input-sm w-48 pl-8 pr-8 dark:bg-coolgray-200" />
<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">
@@ -293,7 +321,7 @@
<div data-log-line data-log-content="{{ htmlspecialchars($searchableContent) }}" <div data-log-line data-log-content="{{ htmlspecialchars($searchableContent) }}"
x-effect="renderTrigger; searchQuery; $el.classList.toggle('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 log-line',
])> ])>
<span x-show="showTimestamps" <span x-show="showTimestamps"
class="shrink-0 text-gray-500">{{ $line['timestamp'] }}</span> class="shrink-0 text-gray-500">{{ $line['timestamp'] }}</span>

View File

@@ -5,17 +5,25 @@
logsLoaded: false, logsLoaded: false,
fullscreen: false, fullscreen: false,
alwaysScroll: false, alwaysScroll: false,
intervalId: null, rafId: null,
scrollDebounce: null, scrollDebounce: null,
colorLogs: localStorage.getItem('coolify-color-logs') === 'true', colorLogs: localStorage.getItem('coolify-color-logs') === 'true',
searchQuery: '', searchQuery: '',
renderTrigger: 0, renderTrigger: 0,
containerName: '{{ $container ?? "logs" }}', containerName: '{{ $container ?? "logs" }}',
// Cache for decoded HTML to avoid repeated DOMParser calls
decodeCache: new Map(),
// Cache for match count to avoid repeated DOM queries
matchCountCache: null,
lastSearchQuery: '',
makeFullscreen() { makeFullscreen() {
this.fullscreen = !this.fullscreen; this.fullscreen = !this.fullscreen;
if (this.fullscreen === false) { if (this.fullscreen === false) {
this.alwaysScroll = false; this.alwaysScroll = false;
clearInterval(this.intervalId); if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
} }
}, },
handleKeyDown(event) { handleKeyDown(event) {
@@ -24,20 +32,33 @@
} }
}, },
isScrolling: false, 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();
// Schedule next scroll after a reasonable delay (250ms instead of 100ms)
if (this.alwaysScroll) {
setTimeout(() => this.scheduleScroll(), 250);
}
});
},
toggleScroll() { toggleScroll() {
this.alwaysScroll = !this.alwaysScroll; this.alwaysScroll = !this.alwaysScroll;
if (this.alwaysScroll) { if (this.alwaysScroll) {
this.intervalId = setInterval(() => { this.scheduleScroll();
const logsContainer = document.getElementById('logsContainer');
if (logsContainer) {
this.isScrolling = true;
logsContainer.scrollTop = logsContainer.scrollHeight;
setTimeout(() => { this.isScrolling = false; }, 50);
}
}, 100);
} else { } else {
clearInterval(this.intervalId); if (this.rafId) {
this.intervalId = null; cancelAnimationFrame(this.rafId);
this.rafId = null;
}
} }
}, },
handleScroll(event) { handleScroll(event) {
@@ -45,7 +66,6 @@
if (!this.alwaysScroll || this.isScrolling) return; if (!this.alwaysScroll || this.isScrolling) return;
// Debounce scroll handling to avoid false positives from DOM mutations // Debounce scroll handling to avoid false positives from DOM mutations
// when Livewire re-renders and adds new log lines
clearTimeout(this.scrollDebounce); clearTimeout(this.scrollDebounce);
this.scrollDebounce = setTimeout(() => { this.scrollDebounce = setTimeout(() => {
const el = event.target; const el = event.target;
@@ -53,8 +73,10 @@
// Use larger threshold (100px) to avoid accidental disables // Use larger threshold (100px) to avoid accidental disables
if (distanceFromBottom > 100) { if (distanceFromBottom > 100) {
this.alwaysScroll = false; this.alwaysScroll = false;
clearInterval(this.intervalId); if (this.rafId) {
this.intervalId = null; cancelAnimationFrame(this.rafId);
this.rafId = null;
}
} }
}, 150); }, 150);
}, },
@@ -93,17 +115,19 @@
} }
const logsContainer = document.getElementById('logs'); const logsContainer = document.getElementById('logs');
if (!logsContainer) return false; if (!logsContainer) return false;
// Check if selection is within the logs container
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
return logsContainer.contains(range.commonAncestorContainer); return logsContainer.contains(range.commonAncestorContainer);
}, },
decodeHtml(text) { decodeHtml(text) {
// Decode HTML entities, handling double-encoding with max iteration limit to prevent DoS // Return cached result if available
if (this.decodeCache.has(text)) {
return this.decodeCache.get(text);
}
// Decode HTML entities with max iteration limit
let decoded = text; let decoded = text;
let prev = ''; let prev = '';
let iterations = 0; let iterations = 0;
const maxIterations = 3; // Prevent DoS from deeply nested HTML entities const maxIterations = 3;
while (decoded !== prev && iterations < maxIterations) { while (decoded !== prev && iterations < maxIterations) {
prev = decoded; prev = decoded;
@@ -111,11 +135,16 @@
decoded = doc.documentElement.textContent; decoded = doc.documentElement.textContent;
iterations++; iterations++;
} }
// Cache the result (limit cache size to prevent memory bloat)
if (this.decodeCache.size > 5000) {
const firstKey = this.decodeCache.keys().next().value;
this.decodeCache.delete(firstKey);
}
this.decodeCache.set(text, decoded);
return decoded; return decoded;
}, },
renderHighlightedLog(el, text) { renderHighlightedLog(el, text) {
// Skip re-render if user has text selected in logs (preserves copy ability) // Skip re-render if user has text selected in logs
// But always render if the element is empty (initial render)
if (el.textContent && this.hasActiveLogSelection()) { if (el.textContent && this.hasActiveLogSelection()) {
return; return;
} }
@@ -134,11 +163,9 @@
let index = lowerText.indexOf(query, lastIndex); let index = lowerText.indexOf(query, lastIndex);
while (index !== -1) { while (index !== -1) {
// Add text before match
if (index > lastIndex) { if (index > lastIndex) {
el.appendChild(document.createTextNode(decoded.substring(lastIndex, index))); el.appendChild(document.createTextNode(decoded.substring(lastIndex, index)));
} }
// Add highlighted match
const mark = document.createElement('span'); const mark = document.createElement('span');
mark.className = 'log-highlight'; mark.className = 'log-highlight';
mark.textContent = decoded.substring(index, index + this.searchQuery.length); mark.textContent = decoded.substring(index, index + this.searchQuery.length);
@@ -148,22 +175,28 @@
index = lowerText.indexOf(query, lastIndex); index = lowerText.indexOf(query, lastIndex);
} }
// Add remaining text
if (lastIndex < decoded.length) { if (lastIndex < decoded.length) {
el.appendChild(document.createTextNode(decoded.substring(lastIndex))); el.appendChild(document.createTextNode(decoded.substring(lastIndex)));
} }
}, },
getMatchCount() { getMatchCount() {
if (!this.searchQuery.trim()) return 0; if (!this.searchQuery.trim()) return 0;
// Return cached count if search query hasn't changed
if (this.lastSearchQuery === this.searchQuery && this.matchCountCache !== null) {
return this.matchCountCache;
}
const logs = document.getElementById('logs'); const logs = document.getElementById('logs');
if (!logs) return 0; if (!logs) return 0;
const lines = logs.querySelectorAll('[data-log-line]'); const lines = logs.querySelectorAll('[data-log-line]');
let count = 0; let count = 0;
const query = this.searchQuery.toLowerCase();
lines.forEach(line => { lines.forEach(line => {
if (line.textContent.toLowerCase().includes(this.searchQuery.toLowerCase())) { if (line.textContent.toLowerCase().includes(query)) {
count++; count++;
} }
}); });
this.matchCountCache = count;
this.lastSearchQuery = this.searchQuery;
return count; return count;
}, },
downloadLogs() { downloadLogs() {
@@ -197,10 +230,18 @@
skip(); skip();
} }
}); });
// Re-render logs after Livewire updates // Re-render logs after Livewire updates (debounced)
let renderTimeout = null;
const debouncedRender = () => {
clearTimeout(renderTimeout);
renderTimeout = setTimeout(() => {
this.matchCountCache = null; // Invalidate match cache on new content
this.renderTrigger++;
}, 100);
};
Livewire.hook('commit', ({ succeed }) => { Livewire.hook('commit', ({ succeed }) => {
succeed(() => { succeed(() => {
this.$nextTick(() => { this.renderTrigger++; }); this.$nextTick(debouncedRender);
}); });
}); });
} }
@@ -393,7 +434,7 @@
'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',
'bg-blue-500/10 dark:bg-blue-500/15': colorLogs && getLogLevel($el.dataset.logContent) === 'info', 'bg-blue-500/10 dark:bg-blue-500/15': colorLogs && getLogLevel($el.dataset.logContent) === 'info',
}" }"
class="flex gap-2"> class="flex gap-2 log-line">
@if ($timestamp && $showTimeStamps) @if ($timestamp && $showTimeStamps)
<span class="shrink-0 text-gray-500">{{ $timestamp }}</span> <span class="shrink-0 text-gray-500">{{ $timestamp }}</span>
@endif @endif