mirror of
https://github.com/coollabsio/coolify.git
synced 2025-12-28 05:34:50 +00:00
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:
parent
20e4783528
commit
acd7106f93
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user