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:
parent
b18d9a254b
commit
40b1b1319f
3 changed files with 147 additions and 67 deletions
|
|
@ -292,3 +292,14 @@ @utility dz-button {
|
|||
@utility xterm {
|
||||
@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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,26 +8,44 @@
|
|||
<div x-data="{
|
||||
fullscreen: @entangle('fullscreen'),
|
||||
alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }},
|
||||
intervalId: null,
|
||||
rafId: null,
|
||||
showTimestamps: true,
|
||||
searchQuery: '',
|
||||
renderTrigger: 0,
|
||||
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() {
|
||||
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() {
|
||||
this.alwaysScroll = !this.alwaysScroll;
|
||||
if (this.alwaysScroll) {
|
||||
this.intervalId = setInterval(() => {
|
||||
const logsContainer = document.getElementById('logsContainer');
|
||||
if (logsContainer) {
|
||||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||||
}
|
||||
}, 100);
|
||||
this.scheduleScroll();
|
||||
} else {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
matchesSearch(text) {
|
||||
|
|
@ -41,17 +59,19 @@
|
|||
}
|
||||
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
|
||||
// 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 prev = '';
|
||||
let iterations = 0;
|
||||
const maxIterations = 3; // Prevent DoS from deeply nested HTML entities
|
||||
const maxIterations = 3;
|
||||
|
||||
while (decoded !== prev && iterations < maxIterations) {
|
||||
prev = decoded;
|
||||
|
|
@ -59,11 +79,17 @@
|
|||
decoded = doc.documentElement.textContent;
|
||||
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;
|
||||
},
|
||||
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)
|
||||
// Skip re-render if user has text selected in logs
|
||||
if (el.textContent && this.hasActiveLogSelection()) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -82,11 +108,9 @@
|
|||
|
||||
let index = lowerText.indexOf(query, lastIndex);
|
||||
while (index !== -1) {
|
||||
// Add text before match
|
||||
if (index > lastIndex) {
|
||||
el.appendChild(document.createTextNode(decoded.substring(lastIndex, index)));
|
||||
}
|
||||
// Add highlighted match
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'log-highlight';
|
||||
mark.textContent = decoded.substring(index, index + this.searchQuery.length);
|
||||
|
|
@ -96,22 +120,28 @@
|
|||
index = lowerText.indexOf(query, lastIndex);
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < decoded.length) {
|
||||
el.appendChild(document.createTextNode(decoded.substring(lastIndex)));
|
||||
}
|
||||
},
|
||||
getMatchCount() {
|
||||
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');
|
||||
if (!logs) return 0;
|
||||
const lines = logs.querySelectorAll('[data-log-line]');
|
||||
let count = 0;
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
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++;
|
||||
}
|
||||
});
|
||||
this.matchCountCache = count;
|
||||
this.lastSearchQuery = this.searchQuery;
|
||||
return count;
|
||||
},
|
||||
downloadLogs() {
|
||||
|
|
@ -135,15 +165,11 @@
|
|||
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.scrollToBottom();
|
||||
this.alwaysScroll = false;
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
},
|
||||
init() {
|
||||
|
|
@ -153,30 +179,32 @@
|
|||
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', () => {
|
||||
this.$nextTick(() => { this.renderTrigger++; });
|
||||
this.$nextTick(debouncedRender);
|
||||
});
|
||||
Livewire.hook('commit', ({ succeed }) => {
|
||||
succeed(() => {
|
||||
this.$nextTick(() => { this.renderTrigger++; });
|
||||
this.$nextTick(debouncedRender);
|
||||
});
|
||||
});
|
||||
// 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) {
|
||||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||||
}
|
||||
}, 100);
|
||||
this.scheduleScroll();
|
||||
}
|
||||
}
|
||||
}">
|
||||
|
|
@ -212,7 +240,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-model.debounce.300ms="searchQuery" placeholder="Find in logs"
|
||||
class="input input-sm w-48 pl-8 pr-8 dark:bg-coolgray-200" />
|
||||
<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">
|
||||
|
|
@ -293,7 +321,7 @@ class="text-gray-500 dark:text-gray-400 py-2">
|
|||
<div data-log-line data-log-content="{{ htmlspecialchars($searchableContent) }}"
|
||||
x-effect="renderTrigger; searchQuery; $el.classList.toggle('hidden', !matchesSearch($el.dataset.logContent))" @class([
|
||||
'mt-2' => isset($line['command']) && $line['command'],
|
||||
'flex gap-2',
|
||||
'flex gap-2 log-line',
|
||||
])>
|
||||
<span x-show="showTimestamps"
|
||||
class="shrink-0 text-gray-500">{{ $line['timestamp'] }}</span>
|
||||
|
|
|
|||
|
|
@ -5,17 +5,25 @@
|
|||
logsLoaded: false,
|
||||
fullscreen: false,
|
||||
alwaysScroll: false,
|
||||
intervalId: null,
|
||||
rafId: null,
|
||||
scrollDebounce: null,
|
||||
colorLogs: localStorage.getItem('coolify-color-logs') === 'true',
|
||||
searchQuery: '',
|
||||
renderTrigger: 0,
|
||||
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() {
|
||||
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,20 +32,33 @@
|
|||
}
|
||||
},
|
||||
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() {
|
||||
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) {
|
||||
|
|
@ -45,7 +66,6 @@
|
|||
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;
|
||||
|
|
@ -53,8 +73,10 @@
|
|||
// 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);
|
||||
},
|
||||
|
|
@ -93,17 +115,19 @@
|
|||
}
|
||||
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
|
||||
// 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 prev = '';
|
||||
let iterations = 0;
|
||||
const maxIterations = 3; // Prevent DoS from deeply nested HTML entities
|
||||
const maxIterations = 3;
|
||||
|
||||
while (decoded !== prev && iterations < maxIterations) {
|
||||
prev = decoded;
|
||||
|
|
@ -111,11 +135,16 @@
|
|||
decoded = doc.documentElement.textContent;
|
||||
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;
|
||||
},
|
||||
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)
|
||||
// Skip re-render if user has text selected in logs
|
||||
if (el.textContent && this.hasActiveLogSelection()) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -134,11 +163,9 @@
|
|||
|
||||
let index = lowerText.indexOf(query, lastIndex);
|
||||
while (index !== -1) {
|
||||
// Add text before match
|
||||
if (index > lastIndex) {
|
||||
el.appendChild(document.createTextNode(decoded.substring(lastIndex, index)));
|
||||
}
|
||||
// Add highlighted match
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'log-highlight';
|
||||
mark.textContent = decoded.substring(index, index + this.searchQuery.length);
|
||||
|
|
@ -148,22 +175,28 @@
|
|||
index = lowerText.indexOf(query, lastIndex);
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < decoded.length) {
|
||||
el.appendChild(document.createTextNode(decoded.substring(lastIndex)));
|
||||
}
|
||||
},
|
||||
getMatchCount() {
|
||||
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');
|
||||
if (!logs) return 0;
|
||||
const lines = logs.querySelectorAll('[data-log-line]');
|
||||
let count = 0;
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
lines.forEach(line => {
|
||||
if (line.textContent.toLowerCase().includes(this.searchQuery.toLowerCase())) {
|
||||
if (line.textContent.toLowerCase().includes(query)) {
|
||||
count++;
|
||||
}
|
||||
});
|
||||
this.matchCountCache = count;
|
||||
this.lastSearchQuery = this.searchQuery;
|
||||
return count;
|
||||
},
|
||||
downloadLogs() {
|
||||
|
|
@ -197,10 +230,18 @@
|
|||
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 }) => {
|
||||
succeed(() => {
|
||||
this.$nextTick(() => { this.renderTrigger++; });
|
||||
this.$nextTick(debouncedRender);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -393,7 +434,7 @@ class="text-gray-500 dark:text-gray-400 py-2">
|
|||
'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">
|
||||
class="flex gap-2 log-line">
|
||||
@if ($timestamp && $showTimeStamps)
|
||||
<span class="shrink-0 text-gray-500">{{ $timestamp }}</span>
|
||||
@endif
|
||||
|
|
|
|||
Loading…
Reference in a new issue