diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index 6a1d3b8760..71e8ad8560 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -21,6 +21,7 @@ use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Process; use Livewire\Attributes\Locked; +use Livewire\Attributes\Renderless; use Livewire\Component; class GetLogs extends Component @@ -33,7 +34,8 @@ class GetLogs extends Component public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB - public string $outputs = ''; + /** Docker log timestamp accepted by `--since`, for example 2026-09-28T10:15:30.123456789Z. */ + private const DOCKER_TIMESTAMP_PATTERN = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?Z$/'; public string $errors = ''; @@ -118,7 +120,6 @@ class GetLogs extends Component try { $this->instantSave(); - $this->getLogs(true); } catch (\Throwable $e) { // Revert the flag to its previous value on failure $this->showTimeStamps = $previousValue; @@ -132,118 +133,60 @@ class GetLogs extends Component $this->streamLogs = ! $this->streamLogs; } - public function showAllLogs(): void + public function showAllLogs(): string { $this->numberOfLines = -1; - $this->getLogs(true); + + return $this->getLogs(); } - public function getLogs($refresh = false) + /** + * Return timestamped log output for the browser-side log viewer. + * + * The output is returned instead of stored in a public property, so it is + * not included in the component snapshot on every request. When `$since` + * holds a Docker timestamp, only lines from that moment onward are returned. + */ + #[Renderless] + public function getLogs(?string $since = null): string { if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) { - $this->outputs = 'Unauthorized.'; - - return; + return 'Unauthorized.'; } if (! $this->server->isFunctional()) { - return; + return ''; } if ($this->container && ! ValidationPatterns::isValidContainerName($this->container)) { - $this->outputs = 'Invalid container name.'; - - return; + return 'Invalid container name.'; } - if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === Service::class || str($this->container)->contains('-pr-'))) { - return; + if (! $this->container) { + return ''; } - $logTail = $this->numberOfLines === -1 ? 'all' : $this->numberOfLines; - if ($logTail !== 'all' && ($logTail <= 0 || is_null($logTail))) { - $this->numberOfLines = 1000; - $logTail = $this->numberOfLines; + + if (is_string($since) && preg_match(self::DOCKER_TIMESTAMP_PATTERN, $since)) { + $options = "--since {$since} -t"; + } else { + $options = "-n {$this->resolveLogTail()} -t"; } - if ($logTail !== 'all' && $logTail > self::MAX_LOG_LINES) { - $this->numberOfLines = self::MAX_LOG_LINES; - $logTail = $this->numberOfLines; - } - if ($this->container) { - if ($this->showTimeStamps) { - if ($this->server->isSwarm()) { - $command = "docker service logs -n {$logTail} -t {$this->container}"; - if ($this->server->isNonRoot()) { - $command = parseCommandsByLineForSudo(collect($command), $this->server); - $command = $command[0]; - } - } else { - $command = "docker logs -n {$logTail} -t {$this->container}"; - if ($this->server->isNonRoot()) { - $command = parseCommandsByLineForSudo(collect($command), $this->server); - $command = $command[0]; - } - } - } else { - if ($this->server->isSwarm()) { - $command = "docker service logs -n {$logTail} {$this->container}"; - if ($this->server->isNonRoot()) { - $command = parseCommandsByLineForSudo(collect($command), $this->server); - $command = $command[0]; - } - } else { - $command = "docker logs -n {$logTail} {$this->container}"; - if ($this->server->isNonRoot()) { - $command = parseCommandsByLineForSudo(collect($command), $this->server); - $command = $command[0]; - } - } - } - $command = $this->boundedLogCommand($command, self::MAX_DISPLAY_SIZE_BYTES); - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); - // Collect new logs into temporary variable first to prevent flickering - // (avoids clearing output before new data is ready) - // Use array accumulation + implode for O(n) instead of O(n²) string concatenation - $logChunks = []; - $accumulatedBytes = 0; - $truncated = false; - Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks, &$accumulatedBytes, &$truncated) { - if ($truncated) { - return; - } - - $remainingBytes = self::MAX_DISPLAY_SIZE_BYTES - $accumulatedBytes; - $outputBytes = strlen($output); - if ($outputBytes > $remainingBytes) { - $logChunks[] = removeAnsiColors(substr($output, 0, max(0, $remainingBytes))); - $truncated = true; - - return; - } - - $logChunks[] = removeAnsiColors($output); - $accumulatedBytes += $outputBytes; - }); - $newOutputs = implode('', $logChunks); - - if ($this->showTimeStamps) { - $newOutputs = str($newOutputs)->split('/\n/')->sort(function ($a, $b) { - $a = explode(' ', $a); - $b = explode(' ', $b); - - return $a[0] <=> $b[0]; - })->join("\n"); - } - - if ($truncated) { - $newOutputs .= "\n\n[... Output truncated at 5MB limit ...]"; - } - - // Only update outputs after new data is ready (atomic update prevents flicker) - $this->outputs = $newOutputs; - } + return $this->runLogCommand($this->logCommand($options), self::MAX_DISPLAY_SIZE_BYTES, '5MB'); } public function copyLogs(): string { - return sanitizeLogsForExport($this->outputs); + if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) { + return ''; + } + if (! $this->server->isFunctional() || ! $this->container) { + return ''; + } + if (! ValidationPatterns::isValidContainerName($this->container)) { + return ''; + } + + $options = "-n {$this->resolveLogTail()}".($this->showTimeStamps ? ' -t' : ''); + + return sanitizeLogsForExport($this->runLogCommand($this->logCommand($options), self::MAX_DISPLAY_SIZE_BYTES, '5MB', $this->showTimeStamps)); } public function downloadAllLogs(): string @@ -258,71 +201,61 @@ class GetLogs extends Component return ''; } - if ($this->showTimeStamps) { - if ($this->server->isSwarm()) { - $command = "docker service logs -t {$this->container}"; - } else { - $command = "docker logs -t {$this->container}"; - } - } else { - if ($this->server->isSwarm()) { - $command = "docker service logs {$this->container}"; - } else { - $command = "docker logs {$this->container}"; - } + $command = $this->logCommand($this->showTimeStamps ? '-t' : ''); + + return sanitizeLogsForExport($this->runLogCommand($command, self::MAX_DOWNLOAD_SIZE_BYTES, '50MB', $this->showTimeStamps)); + } + + private function resolveLogTail(): int|string + { + if ($this->numberOfLines === -1) { + return 'all'; } + if (is_null($this->numberOfLines) || $this->numberOfLines <= 0) { + $this->numberOfLines = 1000; + } + if ($this->numberOfLines > self::MAX_LOG_LINES) { + $this->numberOfLines = self::MAX_LOG_LINES; + } + + return $this->numberOfLines; + } + + private function logCommand(string $options): string + { + $options = trim($options); + $options = $options === '' ? '' : "{$options} "; + $command = $this->server->isSwarm() + ? "docker service logs {$options}{$this->container}" + : "docker logs {$options}{$this->container}"; if ($this->server->isNonRoot()) { - $command = parseCommandsByLineForSudo(collect($command), $this->server); - $command = $command[0]; + $command = parseCommandsByLineForSudo(collect($command), $this->server)[0]; } - $command = $this->boundedLogCommand($command, self::MAX_DOWNLOAD_SIZE_BYTES); - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); + return $command; + } - // Use array accumulation + implode for O(n) instead of O(n²) string concatenation - // Enforce 50MB size limit to prevent memory exhaustion from large logs - $logChunks = []; - $accumulatedBytes = 0; - $truncated = false; + private function runLogCommand(string $command, int $maxBytes, string $limitLabel, bool $timestamped = true): string + { + $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $this->boundedLogCommand($command, $maxBytes)); - Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks, &$accumulatedBytes, &$truncated) { - if ($truncated) { - return; - } - - $outputBytes = strlen($output); - - if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) { - $remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes; - if ($remaining > 0) { - $logChunks[] = removeAnsiColors(substr($output, 0, $remaining)); - } - $truncated = true; - - return; - } - - $logChunks[] = removeAnsiColors($output); - $accumulatedBytes += $outputBytes; - }); - - $allLogs = implode('', $logChunks); - - if ($truncated) { - $allLogs .= "\n\n[... Output truncated at 50MB limit ...]"; - } - - if ($this->showTimeStamps) { - $allLogs = str($allLogs)->split('/\n/')->sort(function ($a, $b) { - $a = explode(' ', $a); - $b = explode(' ', $b); - - return $a[0] <=> $b[0]; + // boundedLogCommand() caps the output at $maxBytes + 1, so one extra byte means truncation. + $output = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand)->output(); + $truncated = strlen($output) > $maxBytes; + $logs = rtrim(removeAnsiColors($truncated ? substr($output, 0, $maxBytes) : $output), "\n"); + if ($timestamped) { + // Docker writes stdout and stderr separately, so sort timestamped lines back into order. + $logs = str($logs)->split('/\n/')->sort(function ($a, $b) { + return explode(' ', $a)[0] <=> explode(' ', $b)[0]; })->join("\n"); } - return sanitizeLogsForExport($allLogs); + if ($truncated) { + $logs .= "\n\n[... Output truncated at {$limitLabel} limit ...]"; + } + + return $logs; } private function boundedLogCommand(string $command, int $maxBytes): string diff --git a/package-lock.json b/package-lock.json index 41c4740165..655097a406 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "dependencies": { "@tailwindcss/forms": "0.5.11", "@tailwindcss/typography": "0.5.20", + "@tanstack/virtual-core": "3.17.11", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", "cobe": "^2.0.1", @@ -666,6 +667,16 @@ "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" } }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.11", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.11.tgz", + "integrity": "sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/prismjs": { "version": "1.26.6", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", diff --git a/package.json b/package.json index a46a750d46..194c340a5a 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dependencies": { "@tailwindcss/forms": "0.5.11", "@tailwindcss/typography": "0.5.20", + "@tanstack/virtual-core": "3.17.11", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", "cobe": "^2.0.1", diff --git a/resources/css/app.css b/resources/css/app.css index 7dbbc247fa..746162b112 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -3477,6 +3477,81 @@ html[data-theme="custom"] .logs-viewer-timestamp { -webkit-appearance: none; } +/* Runtime logs: "Lines", the number field and "All" as one control, styled like the search field. */ +.runtime-log-lines-group { + height: 2rem; + gap: 0; + overflow: hidden; + border: 1px solid var(--color-neutral-200); + border-radius: 0.5rem; + background: #fff; +} + +.dark .runtime-log-lines-group { + border-color: rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.035); +} + +.runtime-log-lines-group:has(.logs-viewer-lines-input:focus) { + border-color: var(--color-coollabs); +} + +.runtime-log-lines-group .logs-viewer-lines-label { + padding: 0 0.5rem 0 0.625rem; +} + +.runtime-log-lines-group .logs-viewer-lines-input { + width: 3.75rem !important; + min-width: 3.75rem !important; + height: 100% !important; + border: 0 !important; + border-left: 1px solid var(--color-neutral-200) !important; + border-right: 1px solid var(--color-neutral-200) !important; + border-radius: 0 !important; + background: transparent !important; + box-shadow: none !important; + outline: none !important; +} + +.dark .runtime-log-lines-group .logs-viewer-lines-input { + border-left-color: rgba(255, 255, 255, 0.08) !important; + border-right-color: rgba(255, 255, 255, 0.08) !important; +} + +.runtime-log-lines-all { + height: 100%; + padding: 0 0.625rem; + font-size: 0.6875rem; + font-weight: 500; + color: #737373; + transition: color 0.15s, background-color 0.15s; +} + +.runtime-log-lines-all:hover:not(:disabled, .runtime-log-lines-all-active) { + background: rgba(0, 0, 0, 0.05); + color: #171717; +} + +.dark .runtime-log-lines-all { + color: var(--color-fg-faint); +} + +.dark .runtime-log-lines-all:hover:not(:disabled, .runtime-log-lines-all-active) { + background: rgba(255, 255, 255, 0.06); + color: var(--color-fg); +} + +.runtime-log-lines-all.runtime-log-lines-all-active, +.dark .runtime-log-lines-all.runtime-log-lines-all-active { + background: var(--color-coollabs); + color: #fff; +} + +.runtime-log-lines-all:disabled { + cursor: not-allowed; + opacity: 0.5; +} + /* Compact badge that fits the dark logs chrome */ .logs-viewer-status-badge { height: 1.5rem !important; @@ -4024,8 +4099,16 @@ html[data-theme="custom"] .runtime-log-columns { color: #adbdc9; } -.runtime-log-viewport [data-log-line].hidden + .runtime-log-detail { - display: none !important; +.runtime-log-virtual-list { + position: relative; + width: 100%; +} + +.runtime-log-virtual-row { + position: absolute; + top: 0; + left: 0; + width: 100%; } .runtime-log-without-time { diff --git a/resources/js/app.js b/resources/js/app.js index b912d827e6..13a2b6778d 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,4 +1,5 @@ import { initializeCopyButtonComponent } from './copy-button.js'; +import { initializeRuntimeLogsComponent } from './runtime-logs.js'; import { initializeSettingsSidebarAccordionComponent } from './settings-sidebar-accordion.js'; import { initializeTerminalComponent } from './terminal.js'; import './traffic-globe.js'; @@ -21,6 +22,7 @@ document.addEventListener('livewire:navigated', () => { // available before Alpine processes terminal markup after wire:navigate. document.addEventListener('alpine:init', initializeTerminalComponent); document.addEventListener('alpine:init', initializeCopyButtonComponent); +document.addEventListener('alpine:init', initializeRuntimeLogsComponent); document.addEventListener('alpine:init', initializeSettingsSidebarAccordionComponent); /** diff --git a/resources/js/runtime-logs.js b/resources/js/runtime-logs.js new file mode 100644 index 0000000000..ad50b2ffbf --- /dev/null +++ b/resources/js/runtime-logs.js @@ -0,0 +1,449 @@ +import { + Virtualizer, + elementScroll, + measureElement, + observeElementOffset, + observeElementRect, +} from '@tanstack/virtual-core'; + +// Alpine data provider for the runtime log viewer (x-data="runtimeLogs(...)"). +// Log lines stay in plain arrays outside Alpine's reactive state. Only the rows +// in the viewport are rendered, so large logs do not freeze the browser tab. + +export const MAX_CLIENT_LOG_LINES = 50000; +export const STREAM_INTERVAL_MS = 2000; +const ESTIMATED_ROW_HEIGHT = 44; + +const TIMESTAMP_PATTERN = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z) (.*)$/s; + +export function getLogLevel(content) { + if (/\b(error|err|failed|failure|exception|fatal|panic|critical)\b/.test(content)) return 'error'; + if (/\b(warn|warning|wrn|caution)\b/.test(content)) return 'warning'; + if (/\b(debug|dbg|trace|verbose)\b/.test(content)) return 'debug'; + return 'info'; +} + +/** + * Split raw `docker logs -t` output into frozen line records. + * Frozen records are not wrapped by Alpine's reactive proxies. + */ +export function parseLogLines(output, nextId) { + const lines = []; + for (const rawLine of String(output ?? '').split('\n')) { + if (rawLine.trim() === '') continue; + const match = rawLine.match(TIMESTAMP_PATTERN); + const ts = match ? match[1] : ''; + const text = match ? match[2] : rawLine; + const lower = text.toLowerCase(); + lines.push(Object.freeze({ id: nextId(), ts, text, lower, level: getLogLevel(lower) })); + } + return lines; +} + +/** + * Remove lines that a `--since` request returns again. Docker includes lines + * with the same timestamp as the `--since` value, so compare that boundary. + */ +export function newLinesSince(existing, incoming) { + let lastTs = ''; + for (let i = existing.length - 1; i >= 0; i--) { + if (existing[i].ts) { + lastTs = existing[i].ts; + break; + } + } + if (!lastTs) return incoming; + + const seenAtBoundary = new Map(); + for (let i = existing.length - 1; i >= 0 && existing[i].ts === lastTs; i--) { + const key = existing[i].text; + seenAtBoundary.set(key, (seenAtBoundary.get(key) ?? 0) + 1); + } + + return incoming.filter((line) => { + if (!line.ts) return true; + if (line.ts < lastTs) return false; + if (line.ts > lastTs) return true; + const seen = seenAtBoundary.get(line.text) ?? 0; + if (seen === 0) return true; + seenAtBoundary.set(line.text, seen - 1); + return false; + }); +} + +export function lastTimestamp(lines) { + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].ts) return lines[i].ts; + } + return null; +} + +/** Ctrl+F or Cmd+F. The viewer takes it, because browser find cannot see rows that are not rendered. */ +export function isFindShortcut(event) { + return Boolean(event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key?.toLowerCase() === 'f'; +} + +export function filterLogLines(lines, filters, query) { + const needle = query.trim().toLowerCase(); + return lines.filter((line) => filters[line.level] !== false && (!needle || line.lower.includes(needle))); +} + +/** Split text into plain and matching parts. The view renders each part with x-text. */ +export function highlightSegments(text, query) { + const needle = query.trim().toLowerCase(); + if (!needle) return [{ text, match: false }]; + + const segments = []; + const lowerText = text.toLowerCase(); + let lastIndex = 0; + let index = lowerText.indexOf(needle); + while (index !== -1) { + if (index > lastIndex) segments.push({ text: text.substring(lastIndex, index), match: false }); + segments.push({ text: text.substring(index, index + needle.length), match: true }); + lastIndex = index + needle.length; + index = lowerText.indexOf(needle, lastIndex); + } + if (lastIndex < text.length) segments.push({ text: text.substring(lastIndex), match: false }); + return segments; +} + +/** Format a Docker UTC timestamp like the server-side log viewer: 2025-Dec-04 11:48:39. */ +export function createTimestampFormatter(timeZone) { + let formatter; + try { + formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timeZone || 'UTC', + year: 'numeric', month: 'short', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', + }); + } catch { + formatter = createTimestampFormatter('UTC').formatter; + } + + const format = (ts) => { + const date = new Date(ts.slice(0, 19) + 'Z'); + if (Number.isNaN(date.getTime())) return ''; + const parts = Object.fromEntries(formatter.formatToParts(date).map((part) => [part.type, part.value])); + return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`; + }; + format.formatter = formatter; + return format; +} + +function downloadText(content, filename) { + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +export function initializeRuntimeLogsComponent() { + window.Alpine.data('runtimeLogs', (config = {}) => { + let lines = []; + let filtered = []; + let lineId = 0; + let virtualizer = null; + let cleanupVirtualizer = null; + let headerObserver = null; + let streamTimer = null; + const nextId = () => ++lineId; + const formatTimestamp = createTimestampFormatter(config.timezone); + + return { + collapsible: config.collapsible ?? true, + expanded: config.expanded ?? false, + containerName: config.containerName ?? 'logs', + findShortcutLabel: /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘F' : 'Ctrl+F', + logsLoaded: false, + loading: false, + fullscreen: false, + alwaysScroll: false, + followManuallyDisabled: false, + isScrolling: false, + lastTouchY: 0, + scrollDebounce: null, + destroyed: false, + colorLogs: localStorage.getItem('coolify-color-logs') === 'true', + logFilters: JSON.parse(localStorage.getItem('coolify-log-filters')) || { error: true, warning: true, debug: true, info: true }, + searchQuery: '', + appliedQuery: '', + matchCount: 0, + lineCount: 0, + expandedLogs: {}, + virtualRows: [], + totalSize: 0, + scrollMargin: 0, + + init() { + this.$watch('searchQuery', () => this.applyFilters()); + this.$nextTick(() => this.mountVirtualizer()); + streamTimer = setInterval(() => this.streamTick(), STREAM_INTERVAL_MS); + if (this.expanded) { + this.refresh(); + this.logsLoaded = true; + } + }, + + destroy() { + this.destroyed = true; + clearInterval(streamTimer); + clearTimeout(this.scrollDebounce); + headerObserver?.disconnect(); + cleanupVirtualizer?.(); + virtualizer = null; + }, + + mountVirtualizer() { + if (this.destroyed || virtualizer) return; + virtualizer = new Virtualizer({ + count: 0, + getScrollElement: () => this.$refs.viewport ?? null, + estimateSize: () => ESTIMATED_ROW_HEIGHT, + getItemKey: (index) => filtered[index]?.id ?? index, + overscan: 12, + // Keep the visible rows in place when old lines are trimmed from the top. + anchorTo: 'end', + observeElementRect, + observeElementOffset, + scrollToFn: elementScroll, + measureElement, + onChange: () => this.syncRows(), + }); + cleanupVirtualizer = virtualizer._didMount(); + virtualizer._willUpdate(); + if (this.$refs.columns && window.ResizeObserver) { + headerObserver = new ResizeObserver(() => this.updateVirtualizer()); + headerObserver.observe(this.$refs.columns); + } + this.updateVirtualizer(); + }, + + updateVirtualizer() { + if (!virtualizer) return; + this.scrollMargin = this.$refs.list?.offsetTop ?? 0; + virtualizer.setOptions({ + ...virtualizer.options, + count: filtered.length, + scrollMargin: this.scrollMargin, + // A new function makes the virtualizer recalculate keys after every data change. + getItemKey: (index) => filtered[index]?.id ?? index, + }); + virtualizer._willUpdate(); + this.syncRows(); + }, + + syncRows() { + if (!virtualizer) return; + this.virtualRows = virtualizer.getVirtualItems() + .filter((item) => filtered[item.index]) + .map((item) => ({ key: item.key, index: item.index, start: item.start, line: filtered[item.index] })); + this.totalSize = virtualizer.getTotalSize(); + }, + + measureRow(el) { + virtualizer?.measureElement(el); + }, + + async refresh() { + if (this.loading) return; + this.loading = true; + try { + this.replaceLines(await this.$wire.getLogs()); + } finally { + this.loading = false; + } + }, + + async showAllLogs() { + if (this.loading) return; + this.loading = true; + try { + this.replaceLines(await this.$wire.showAllLogs()); + } finally { + this.loading = false; + } + }, + + async streamTick() { + if (this.destroyed || this.loading || !this.expanded || document.hidden || !this.$wire.streamLogs) return; + const since = lastTimestamp(lines); + if (!since) { + await this.refresh(); + return; + } + this.loading = true; + try { + const output = await this.$wire.getLogs(since); + if (this.destroyed || !this.$wire.streamLogs) return; + this.appendLines(newLinesSince(lines, parseLogLines(output, nextId))); + } finally { + this.loading = false; + } + }, + + replaceLines(output) { + if (this.destroyed) return; + lines = parseLogLines(output, nextId); + this.expandedLogs = {}; + this.applyFilters(); + }, + + appendLines(newLines) { + if (newLines.length === 0) return; + lines = lines.concat(newLines); + if (lines.length > MAX_CLIENT_LOG_LINES) { + lines = lines.slice(lines.length - MAX_CLIENT_LOG_LINES); + } + this.applyFilters(); + }, + + applyFilters() { + filtered = filterLogLines(lines, this.logFilters, this.searchQuery); + this.appliedQuery = this.searchQuery.trim(); + this.matchCount = this.appliedQuery ? filtered.length : 0; + this.lineCount = lines.length; + // Wait for x-show to reveal the list so its offset can be measured. + this.$nextTick(() => { + this.updateVirtualizer(); + if (this.alwaysScroll) this.scrollToBottom(); + }); + }, + + visibleText() { + const withTime = this.$wire.showTimeStamps; + return filtered + .map((line) => (withTime && line.ts ? `${formatTimestamp(line.ts)} ${line.text}` : line.text)) + .join('\n'); + }, + + formatTimestamp(ts) { + return formatTimestamp(ts); + }, + + highlight(text) { + return highlightSegments(text, this.appliedQuery); + }, + + formatLogDetails(content) { + try { + return JSON.stringify(JSON.parse(content), null, 2); + } catch { + return content; + } + }, + + toggleLogDetails(id, event) { + if (window.getSelection()?.toString()) return; + if (event.type === 'keydown' && !['Enter', ' '].includes(event.key)) return; + if (event.type === 'keydown') event.preventDefault(); + this.expandedLogs[id] = !this.expandedLogs[id]; + }, + + isLogExpanded(id) { + return this.expandedLogs[id] === true; + }, + + toggleLogFilter(level) { + this.logFilters[level] = !this.logFilters[level]; + localStorage.setItem('coolify-log-filters', JSON.stringify(this.logFilters)); + this.applyFilters(); + }, + + toggleColorLogs() { + this.colorLogs = !this.colorLogs; + localStorage.setItem('coolify-color-logs', this.colorLogs); + }, + + downloadLogs() { + const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-'); + downloadText(this.visibleText() + '\n', `${this.containerName}-logs-${timestamp}.txt`); + }, + + makeFullscreen() { + this.fullscreen = !this.fullscreen; + if (this.fullscreen === false) { + this.alwaysScroll = false; + } + }, + + handleKeyDown(event) { + if (event.key === 'Escape' && this.fullscreen) { + this.makeFullscreen(); + } + if (isFindShortcut(event) && this.ownsFindShortcut()) { + event.preventDefault(); + this.$refs.search.focus(); + this.$refs.search.select(); + } + }, + + // Focus inside this viewer wins. Otherwise, unless the user types in another + // field or works in another viewer, the fullscreen viewer or else the first + // open viewer takes it. A second Ctrl+F in the search field falls through + // to the browser find. + ownsFindShortcut() { + if (!this.expanded) return false; + const active = document.activeElement; + if (active === this.$refs.search) return false; + if (this.$root.contains(active)) return true; + if (active?.closest('[data-runtime-logs]')) return false; + if (active?.isContentEditable || active?.matches('input, textarea, select')) return false; + const target = document.querySelector('[data-runtime-logs="fullscreen"]') + ?? document.querySelector('[data-runtime-logs="expanded"]'); + return target === this.$root; + }, + + scrollToBottom() { + if (this.destroyed || !virtualizer || filtered.length === 0) return; + this.isScrolling = true; + virtualizer.scrollToIndex(filtered.length - 1, { align: 'end' }); + setTimeout(() => { this.isScrolling = false; }, 50); + }, + + toggleScroll() { + this.alwaysScroll = !this.alwaysScroll; + this.followManuallyDisabled = !this.alwaysScroll; + if (this.alwaysScroll) this.scrollToBottom(); + }, + + disableFollow() { + this.alwaysScroll = false; + }, + + handleWheel(event) { + if (this.alwaysScroll && event.deltaY < 0) this.disableFollow(); + }, + + handleTouchStart(event) { + this.lastTouchY = event.touches[0].clientY; + }, + + handleTouchMove(event) { + if (!this.alwaysScroll) return; + const currentY = event.touches[0].clientY; + if (currentY > this.lastTouchY) this.disableFollow(); + this.lastTouchY = currentY; + }, + + handleKeyScroll(event) { + if (this.alwaysScroll && ['ArrowUp', 'PageUp', 'Home'].includes(event.key)) this.disableFollow(); + }, + + handleScroll(event) { + if (this.isScrolling || this.destroyed) return; + clearTimeout(this.scrollDebounce); + this.scrollDebounce = setTimeout(() => { + if (this.destroyed) return; + const el = event.target; + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + if (!this.alwaysScroll && !this.followManuallyDisabled && distanceFromBottom <= 10) { + this.alwaysScroll = true; + } + }, 150); + }, + }; + }); +} diff --git a/resources/js/runtime-logs.test.js b/resources/js/runtime-logs.test.js new file mode 100644 index 0000000000..c802f9b2bb --- /dev/null +++ b/resources/js/runtime-logs.test.js @@ -0,0 +1,98 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createTimestampFormatter, + filterLogLines, + getLogLevel, + highlightSegments, + isFindShortcut, + lastTimestamp, + newLinesSince, + parseLogLines, +} from './runtime-logs.js'; + +const idGenerator = () => { + let id = 0; + return () => ++id; +}; + +test('parses docker timestamped lines and skips blank lines', () => { + const lines = parseLogLines( + '2026-09-28T10:00:00.123456789Z Server started\n\n2026-09-28T10:00:01Z ERROR failed to connect\nplain line', + idGenerator(), + ); + + assert.deepEqual(lines.map(({ id, ts, text, level }) => ({ id, ts, text, level })), [ + { id: 1, ts: '2026-09-28T10:00:00.123456789Z', text: 'Server started', level: 'info' }, + { id: 2, ts: '2026-09-28T10:00:01Z', text: 'ERROR failed to connect', level: 'error' }, + { id: 3, ts: '', text: 'plain line', level: 'info' }, + ]); + assert.ok(Object.isFrozen(lines[0])); +}); + +test('detects log levels by keyword', () => { + assert.equal(getLogLevel('fatal: boom'), 'error'); + assert.equal(getLogLevel('warning: disk'), 'warning'); + assert.equal(getLogLevel('trace id=1'), 'debug'); + assert.equal(getLogLevel('ready'), 'info'); +}); + +test('filters by level and case-insensitive query', () => { + const lines = parseLogLines('ERROR Database down\nINFO Database up\nwarn cache miss', idGenerator()); + + assert.deepEqual(filterLogLines(lines, { error: true, warning: true, debug: true, info: true }, 'DATABASE').map((l) => l.text), [ + 'ERROR Database down', + 'INFO Database up', + ]); + assert.deepEqual(filterLogLines(lines, { error: false, warning: true, debug: true, info: true }, '').map((l) => l.text), [ + 'INFO Database up', + 'warn cache miss', + ]); +}); + +test('drops lines that a --since request returns again', () => { + const nextId = idGenerator(); + const existing = parseLogLines( + '2026-09-28T10:00:00Z first\n2026-09-28T10:00:01Z same-second a\n2026-09-28T10:00:01Z same-second b', + nextId, + ); + const incoming = parseLogLines( + '2026-09-28T10:00:01Z same-second a\n2026-09-28T10:00:01Z same-second b\n2026-09-28T10:00:01Z same-second c\n2026-09-28T10:00:02Z next', + nextId, + ); + + assert.equal(lastTimestamp(existing), '2026-09-28T10:00:01Z'); + assert.deepEqual(newLinesSince(existing, incoming).map((l) => l.text), ['same-second c', 'next']); +}); + +test('keeps repeated identical lines that are new at the boundary', () => { + const nextId = idGenerator(); + const existing = parseLogLines('2026-09-28T10:00:01Z ping', nextId); + const incoming = parseLogLines('2026-09-28T10:00:01Z ping\n2026-09-28T10:00:01Z ping', nextId); + + assert.deepEqual(newLinesSince(existing, incoming).map((l) => l.text), ['ping']); +}); + +test('splits text into highlight segments without markup', () => { + assert.deepEqual(highlightSegments('Error error', 'error'), [ + { text: '', match: false }, + { text: 'Error', match: true }, + { text: ' ', match: false }, + { text: 'error', match: true }, + ]); + assert.deepEqual(highlightSegments('plain', ' '), [{ text: 'plain', match: false }]); +}); + +test('formats timestamps in the server timezone and falls back to UTC', () => { + assert.equal(createTimestampFormatter('Europe/Budapest')('2025-12-04T11:48:39.136764033Z'), '2025-Dec-04 12:48:39'); + assert.equal(createTimestampFormatter('Not/AZone')('2025-12-04T11:48:39Z'), '2025-Dec-04 11:48:39'); +}); + +test('detects Ctrl+F and Cmd+F only', () => { + assert.equal(isFindShortcut({ ctrlKey: true, key: 'f' }), true); + assert.equal(isFindShortcut({ metaKey: true, key: 'F' }), true); + assert.equal(isFindShortcut({ key: 'f' }), false); + assert.equal(isFindShortcut({ ctrlKey: true, shiftKey: true, key: 'F' }), false); + assert.equal(isFindShortcut({ ctrlKey: true, altKey: true, key: 'f' }), false); + assert.equal(isFindShortcut({ ctrlKey: true, key: 'g' }), false); +}); diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php index 400e6cd315..407e2da96b 100644 --- a/resources/views/livewire/project/shared/get-logs.blade.php +++ b/resources/views/livewire/project/shared/get-logs.blade.php @@ -1,295 +1,14 @@