mirror of
https://github.com/tiennm99/coolify.git
synced 2026-04-17 17:21:04 +00:00
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:
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user