Refactor log view to eliminate flickering during refresh

- Use atomic update pattern in backend: collect logs into temp variable
  before replacing outputs (prevents empty state flash)
- Remove per-line x-effect directives that caused 4000+ reactive
  evaluations on every update
- Replace inline Alpine.js class bindings with CSS utility classes
- Use single $watch and morph.updated hook instead of renderTrigger
- Remove HTML entity decode cache (no longer needed)
- Fix search highlight padding that caused text shifting

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai 2025-12-18 08:46:39 +01:00
parent 20e4783528
commit acd7106f93
3 changed files with 104 additions and 143 deletions

View File

@ -67,11 +67,6 @@ class GetLogs extends Component
} }
} }
public function doSomethingWithThisChunkOfOutput($output)
{
$this->outputs .= removeAnsiColors($output);
}
public function instantSave() public function instantSave()
{ {
if (! is_null($this->resource)) { if (! is_null($this->resource)) {
@ -162,20 +157,24 @@ class GetLogs extends Component
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
} }
} }
if ($refresh) { // Collect new logs into temporary variable first to prevent flickering
$this->outputs = ''; // (avoids clearing output before new data is ready)
} $newOutputs = '';
Process::run($sshCommand, function (string $type, string $output) { Process::run($sshCommand, function (string $type, string $output) use (&$newOutputs) {
$this->doSomethingWithThisChunkOfOutput($output); $newOutputs .= removeAnsiColors($output);
}); });
if ($this->showTimeStamps) { if ($this->showTimeStamps) {
$this->outputs = str($this->outputs)->split('/\n/')->sort(function ($a, $b) { $newOutputs = str($newOutputs)->split('/\n/')->sort(function ($a, $b) {
$a = explode(' ', $a); $a = explode(' ', $a);
$b = explode(' ', $b); $b = explode(' ', $b);
return $a[0] <=> $b[0]; return $a[0] <=> $b[0];
})->join("\n"); })->join("\n");
} }
// Only update outputs after new data is ready (atomic update prevents flicker)
$this->outputs = $newOutputs;
} }
} }

View File

@ -301,5 +301,22 @@
/* Search highlight styling for logs */ /* Search highlight styling for logs */
@utility log-highlight { @utility log-highlight {
@apply bg-warning/40 dark:bg-warning/30 rounded-sm px-0.5; @apply bg-warning/40 dark:bg-warning/30;
}
/* Log level color classes */
@utility log-error {
@apply bg-red-500/10 dark:bg-red-500/15;
}
@utility log-warning {
@apply bg-yellow-500/10 dark:bg-yellow-500/15;
}
@utility log-debug {
@apply bg-purple-500/10 dark:bg-purple-500/15;
}
@utility log-info {
@apply bg-blue-500/10 dark:bg-blue-500/15;
} }

View File

@ -9,13 +9,8 @@
scrollDebounce: null, scrollDebounce: null,
colorLogs: localStorage.getItem('coolify-color-logs') === 'true', colorLogs: localStorage.getItem('coolify-color-logs') === 'true',
searchQuery: '', searchQuery: '',
renderTrigger: 0, matchCount: 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) {
@ -44,7 +39,6 @@
if (!this.alwaysScroll) return; if (!this.alwaysScroll) return;
this.rafId = requestAnimationFrame(() => { this.rafId = requestAnimationFrame(() => {
this.scrollToBottom(); this.scrollToBottom();
// Schedule next scroll after a reasonable delay (250ms instead of 100ms)
if (this.alwaysScroll) { if (this.alwaysScroll) {
setTimeout(() => this.scheduleScroll(), 250); setTimeout(() => this.scheduleScroll(), 250);
} }
@ -62,15 +56,11 @@
} }
}, },
handleScroll(event) { handleScroll(event) {
// Skip if follow logs is disabled or this is a programmatic scroll
if (!this.alwaysScroll || this.isScrolling) return; if (!this.alwaysScroll || this.isScrolling) return;
// Debounce scroll handling to avoid false positives from DOM mutations
clearTimeout(this.scrollDebounce); clearTimeout(this.scrollDebounce);
this.scrollDebounce = setTimeout(() => { this.scrollDebounce = setTimeout(() => {
const el = event.target; const el = event.target;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
// Use larger threshold (100px) to avoid accidental disables
if (distanceFromBottom > 100) { if (distanceFromBottom > 100) {
this.alwaysScroll = false; this.alwaysScroll = false;
if (this.rafId) { if (this.rafId) {
@ -83,30 +73,26 @@
toggleColorLogs() { toggleColorLogs() {
this.colorLogs = !this.colorLogs; this.colorLogs = !this.colorLogs;
localStorage.setItem('coolify-color-logs', this.colorLogs); localStorage.setItem('coolify-color-logs', this.colorLogs);
this.applyColorLogs();
}, },
getLogLevel(text) { applyColorLogs() {
const lowerText = text.toLowerCase(); const logs = document.getElementById('logs');
// Error detection (highest priority) if (!logs) return;
if (/\b(error|err|failed|failure|exception|fatal|panic|critical)\b/.test(lowerText)) { const lines = logs.querySelectorAll('[data-log-line]');
return 'error'; lines.forEach(line => {
} const content = (line.dataset.logContent || '').toLowerCase();
// Warning detection line.classList.remove('log-error', 'log-warning', 'log-debug', 'log-info');
if (/\b(warn|warning|wrn|caution)\b/.test(lowerText)) { if (!this.colorLogs) return;
return 'warning'; if (/\b(error|err|failed|failure|exception|fatal|panic|critical)\b/.test(content)) {
} line.classList.add('log-error');
// Debug detection } else if (/\b(warn|warning|wrn|caution)\b/.test(content)) {
if (/\b(debug|dbg|trace|verbose)\b/.test(lowerText)) { line.classList.add('log-warning');
return 'debug'; } else if (/\b(debug|dbg|trace|verbose)\b/.test(content)) {
} line.classList.add('log-debug');
// Info detection } else if (/\b(info|inf|notice)\b/.test(content)) {
if (/\b(info|inf|notice)\b/.test(lowerText)) { line.classList.add('log-info');
return 'info'; }
} });
return null;
},
matchesSearch(line) {
if (!this.searchQuery.trim()) return true;
return line.toLowerCase().includes(this.searchQuery.toLowerCase());
}, },
hasActiveLogSelection() { hasActiveLogSelection() {
const selection = window.getSelection(); const selection = window.getSelection();
@ -118,87 +104,59 @@
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
return logsContainer.contains(range.commonAncestorContainer); return logsContainer.contains(range.commonAncestorContainer);
}, },
decodeHtml(text) { applySearch() {
// Return cached result if available const logs = document.getElementById('logs');
if (this.decodeCache.has(text)) { if (!logs) return;
return this.decodeCache.get(text); const lines = logs.querySelectorAll('[data-log-line]');
} const query = this.searchQuery.trim().toLowerCase();
// Decode HTML entities with max iteration limit let count = 0;
let decoded = text;
let prev = '';
let iterations = 0;
const maxIterations = 3;
while (decoded !== prev && iterations < maxIterations) { lines.forEach(line => {
prev = decoded; const content = (line.dataset.logContent || '').toLowerCase();
const doc = new DOMParser().parseFromString(decoded, 'text/html'); const textSpan = line.querySelector('[data-line-text]');
decoded = doc.documentElement.textContent; const matches = !query || content.includes(query);
iterations++;
} line.classList.toggle('hidden', !matches);
// Cache the result (limit cache size to prevent memory bloat) if (matches && query) count++;
if (this.decodeCache.size > 5000) {
const firstKey = this.decodeCache.keys().next().value; // Update highlighting
this.decodeCache.delete(firstKey); if (textSpan) {
} const originalText = textSpan.dataset.lineText || '';
this.decodeCache.set(text, decoded); if (!query) {
return decoded; textSpan.textContent = originalText;
} else if (matches) {
this.highlightText(textSpan, originalText, query);
}
}
});
this.matchCount = query ? count : 0;
}, },
renderHighlightedLog(el, text) { highlightText(el, text, query) {
// Skip re-render if user has text selected in logs // Skip if user has selection
if (el.textContent && this.hasActiveLogSelection()) { if (this.hasActiveLogSelection()) return;
return;
}
const decoded = this.decodeHtml(text);
el.textContent = ''; el.textContent = '';
const lowerText = text.toLowerCase();
if (!this.searchQuery.trim()) {
el.textContent = decoded;
return;
}
const query = this.searchQuery.toLowerCase();
const lowerText = decoded.toLowerCase();
let lastIndex = 0; let lastIndex = 0;
let index = lowerText.indexOf(query, lastIndex); let index = lowerText.indexOf(query, lastIndex);
while (index !== -1) { while (index !== -1) {
if (index > lastIndex) { if (index > lastIndex) {
el.appendChild(document.createTextNode(decoded.substring(lastIndex, index))); el.appendChild(document.createTextNode(text.substring(lastIndex, index)));
} }
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 = text.substring(index, index + query.length);
el.appendChild(mark); el.appendChild(mark);
lastIndex = index + query.length;
lastIndex = index + this.searchQuery.length;
index = lowerText.indexOf(query, lastIndex); index = lowerText.indexOf(query, lastIndex);
} }
if (lastIndex < decoded.length) { if (lastIndex < text.length) {
el.appendChild(document.createTextNode(decoded.substring(lastIndex))); el.appendChild(document.createTextNode(text.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(query)) {
count++;
}
});
this.matchCountCache = count;
this.lastSearchQuery = this.searchQuery;
return count;
},
downloadLogs() { downloadLogs() {
const logs = document.getElementById('logs'); const logs = document.getElementById('logs');
if (!logs) return; if (!logs) return;
@ -224,25 +182,23 @@
this.$wire.getLogs(true); this.$wire.getLogs(true);
this.logsLoaded = true; this.logsLoaded = true;
} }
// Prevent Livewire from morphing logs container when text is selected
Livewire.hook('morph.updating', ({ el, component, toEl, skip }) => { // Watch search query changes
if (el.id === 'logs' && this.hasActiveLogSelection()) { this.$watch('searchQuery', () => {
skip(); this.applySearch();
}
}); });
// Re-render logs after Livewire updates (debounced)
let renderTimeout = null; // Apply colors after Livewire updates
const debouncedRender = () => { Livewire.hook('morph.updated', ({ el }) => {
clearTimeout(renderTimeout); if (el.id === 'logs') {
renderTimeout = setTimeout(() => { this.$nextTick(() => {
this.matchCountCache = null; // Invalidate match cache on new content this.applyColorLogs();
this.renderTrigger++; this.applySearch();
}, 100); if (this.alwaysScroll) {
}; this.scrollToBottom();
Livewire.hook('commit', ({ succeed }) => { }
succeed(() => { });
this.$nextTick(debouncedRender); }
});
}); });
} }
}" @keydown.window="handleKeyDown($event)"> }" @keydown.window="handleKeyDown($event)">
@ -283,7 +239,7 @@
title="Number of Lines" {{ $streamLogs ? 'readonly' : '' }} title="Number of Lines" {{ $streamLogs ? 'readonly' : '' }}
class="input input-sm w-32 pl-11 text-center dark:bg-coolgray-300" /> class="input input-sm w-32 pl-11 text-center dark:bg-coolgray-300" />
</form> </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> class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"></span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -400,7 +356,7 @@
Showing last {{ number_format($maxDisplayLines) }} of {{ number_format($totalLines) }} lines Showing last {{ number_format($maxDisplayLines) }} of {{ number_format($totalLines) }} lines
</div> </div>
@endif @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"> class="text-gray-500 dark:text-gray-400 py-2">
No matches found. No matches found.
</div> </div>
@ -424,23 +380,12 @@
// Format: 2025-Dec-04 09:44:58.198879 // Format: 2025-Dec-04 09:44:58.198879
$timestamp = "{$year}-{$monthName}-{$day} {$time}.{$microseconds}"; $timestamp = "{$year}-{$monthName}-{$day} {$time}.{$microseconds}";
} }
@endphp @endphp
<div data-log-line data-log-content="{{ $line }}" <div data-log-line data-log-content="{{ $line }}" class="flex gap-2 log-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 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
<span data-line-text="{{ $logContent }}" <span data-line-text="{{ $logContent }}" class="whitespace-pre-wrap break-all">{{ $logContent }}</span>
x-effect="renderTrigger; searchQuery; renderHighlightedLog($el, $el.dataset.lineText)"
class="whitespace-pre-wrap break-all"></span>
</div> </div>
@endforeach @endforeach
</div> </div>