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 @@
$collapsible])> -
this.lastTouchY) { - this.disableFollow(); - } - this.lastTouchY = currentY; - }, - handleKeyScroll(event) { - if (!this.alwaysScroll) return; - const upKeys = ['ArrowUp', 'PageUp', 'Home']; - if (upKeys.includes(event.key)) { - this.disableFollow(); - } - }, - scrollToBottom() { - if (this.destroyed) return; - const logsContainer = this.$root.querySelector('#logsContainer'); - if (logsContainer) { - this.isScrolling = true; - logsContainer.scrollTop = logsContainer.scrollHeight; - setTimeout(() => { this.isScrolling = false; }, 50); - } - }, - scheduleScroll() { - if (!this.alwaysScroll || this.destroyed) return; - this.rafId = requestAnimationFrame(() => { - if (!this.alwaysScroll || this.destroyed) return; - this.scrollToBottom(); - if (this.alwaysScroll && !this.destroyed) { - this.scrollTimeout = setTimeout(() => this.scheduleScroll(), 250); - } - }); - }, - toggleScroll() { - this.alwaysScroll = !this.alwaysScroll; - if (this.alwaysScroll) { - this.followManuallyDisabled = false; - this.scheduleScroll(); - } else { - this.followManuallyDisabled = true; - this.cancelScrollLoop(); - } - }, - 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; - this.scheduleScroll(); - } - }, 150); - }, - 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'; - }, - toggleLogDetails(key, event) { - if (window.getSelection()?.toString()) return; - if (event.type === 'keydown' && !['Enter', ' '].includes(event.key)) return; - if (event.type === 'keydown') event.preventDefault(); - this.expandedLogs[key] = !this.expandedLogs[key]; - }, - isLogExpanded(key) { - return this.expandedLogs[key] === true; - }, - formatLogDetails(content) { - try { - return JSON.stringify(JSON.parse(content), null, 2); - } catch { - return content; - } - }, - toggleLogFilter(level) { - this.logFilters[level] = !this.logFilters[level]; - localStorage.setItem('coolify-log-filters', JSON.stringify(this.logFilters)); - this.applySearch(); - }, - toggleColorLogs() { - this.colorLogs = !this.colorLogs; - localStorage.setItem('coolify-color-logs', this.colorLogs); - this.applyColorLogs(); - }, - applyColorLogs() { - const logs = document.getElementById('logs'); - if (!logs) return; - const lines = logs.querySelectorAll('[data-log-line]'); - lines.forEach(line => { - const content = (line.dataset.logContent || '').toLowerCase(); - const level = this.getLogLevel(content); - line.dataset.logLevel = level; - line.classList.remove('log-error', 'log-warning', 'log-debug', 'log-info'); - if (!this.colorLogs) return; - line.classList.add('log-' + level); - }); - }, - hasActiveLogSelection() { - const selection = window.getSelection(); - if (!selection || selection.isCollapsed || !selection.toString().trim()) { - return false; - } - const logsContainer = document.getElementById('logs'); - if (!logsContainer) return false; - const range = selection.getRangeAt(0); - return logsContainer.contains(range.commonAncestorContainer); - }, - applySearch() { - const logs = document.getElementById('logs'); - if (!logs) return; - const lines = logs.querySelectorAll('[data-log-line]'); - const query = this.searchQuery.trim().toLowerCase(); - let count = 0; - - lines.forEach(line => { - const content = (line.dataset.logContent || '').toLowerCase(); - const textSpan = line.querySelector('[data-line-text]'); - const level = line.dataset.logLevel || this.getLogLevel(content); - const passesFilter = this.logFilters[level] !== false; - const matchesSearch = !query || content.includes(query); - const matches = passesFilter && matchesSearch; - - line.classList.toggle('hidden', !matches); - if (matches && query) count++; - - // Update highlighting - if (textSpan) { - const originalText = textSpan.dataset.lineText || ''; - if (!query) { - textSpan.textContent = originalText; - } else if (matches) { - this.highlightText(textSpan, originalText, query); - } - } - }); - - this.matchCount = query ? count : 0; - }, - highlightText(el, text, query) { - // Skip if user has selection - if (this.hasActiveLogSelection()) return; - - el.textContent = ''; - const lowerText = text.toLowerCase(); - let lastIndex = 0; - let index = lowerText.indexOf(query, lastIndex); - - while (index !== -1) { - if (index > lastIndex) { - el.appendChild(document.createTextNode(text.substring(lastIndex, index))); - } - const mark = document.createElement('span'); - mark.className = 'log-highlight'; - mark.textContent = text.substring(index, index + query.length); - el.appendChild(mark); - lastIndex = index + query.length; - index = lowerText.indexOf(query, lastIndex); - } - - if (lastIndex < text.length) { - el.appendChild(document.createTextNode(text.substring(lastIndex))); - } - }, - downloadLogs() { - const logs = document.getElementById('logs'); - if (!logs) return; - const visibleLines = logs.querySelectorAll('[data-log-line]:not(.hidden)'); - let content = ''; - visibleLines.forEach(line => { - const text = line.textContent.replace(/\s+/g, ' ').trim(); - if (text) { - content += text + String.fromCharCode(10); - } - }); - const blob = new Blob([content], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - const timestamp = new Date().toISOString().slice(0,19).replace(/[T:]/g, '-'); - a.download = this.containerName + '-logs-' + timestamp + '.txt'; - a.click(); - URL.revokeObjectURL(url); - }, - init() { - if (this.expanded) { - this.$wire.getLogs(true); - this.logsLoaded = true; - } - - // Watch search query changes - this.$watch('searchQuery', () => { - this.applySearch(); - }); - - // Handler for applying colors and search after DOM changes - const applyAfterUpdate = () => { - this.$nextTick(() => { - this.applyColorLogs(); - this.applySearch(); - if (this.alwaysScroll) { - this.scrollToBottom(); - } - }); - }; - - // Apply colors after Livewire updates (existing content) - Livewire.hook('morph.updated', ({ el }) => { - if (el.id === 'logs') { - applyAfterUpdate(); - } - }); - - // Apply colors after Livewire adds new content (initial load) - Livewire.hook('morph.added', ({ el }) => { - if (el.id === 'logs') { - applyAfterUpdate(); - } - }); - }, - destroy() { - this.destroyed = true; - this.alwaysScroll = false; - this.cancelScrollLoop(); - } - }" @keydown.window="handleKeyDown($event)"> +
@if ($collapsible)
+ x-on:click="expanded = !expanded; if (expanded && !logsLoaded) { refresh(); logsLoaded = true; }"> @@ -309,9 +28,6 @@ @endif
@endif - @if ($streamLogs) - - @endif
@@ -320,7 +36,7 @@
- @@ -499,23 +215,25 @@
-
+ Lines - +
-
-
- @if ($outputs) - @php - $displayLines = collect(explode("\n", $outputs))->filter(fn($line) => trim($line) !== ''); - $lineOccurrences = []; - @endphp -
!$showTimeStamps, - ])> - -
- No matches found. -
- @foreach ($displayLines as $line) - @php - $lineFingerprint = md5($line); - $lineOccurrence = $lineOccurrences[$lineFingerprint] ?? 0; - $lineOccurrences[$lineFingerprint] = $lineOccurrence + 1; - - // Parse timestamp from log line (ISO 8601 format: 2025-12-04T11:48:39.136764033Z) - $timestamp = ''; - $logContent = $line; - if (preg_match('/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?Z?\s(.*)$/', $line, $matches)) { - $logContent = $matches[3]; - - // Convert UTC Docker timestamp to server timezone for display - $carbonTs = \Carbon\Carbon::parse($matches[1], 'UTC'); - $serverTz = getServerTimezone($server); - try { - $carbonTs->setTimezone($serverTz); - } catch (\Exception) { - // keep UTC - } - $timestamp = $carbonTs->format('Y-M-d H:i:s'); - } - @endphp - @php($lineKey = $lineFingerprint.'-'.$lineOccurrence) -
- @if ($timestamp && $showTimeStamps) - {{ $timestamp }} - @endif - {{ $logContent }} +
+ + Loading logs +
+
+ +
+

No logs yet

+ Logs will appear here when the container produces output. +
+
+ {{-- Alpine renders only the visible rows. Livewire must not morph them. --}} +
+ +
+
- @else -
- - Loading logs +
+ No matches found.
-
- -
-

No logs yet

- Logs will appear here when the container produces output. -
-
- @endif +
diff --git a/tests/Feature/GetLogsCommandInjectionTest.php b/tests/Feature/GetLogsCommandInjectionTest.php index f7aac5fcb4..c2ff1eb50b 100644 --- a/tests/Feature/GetLogsCommandInjectionTest.php +++ b/tests/Feature/GetLogsCommandInjectionTest.php @@ -11,7 +11,6 @@ use App\Models\Team; use App\Models\User; use App\Support\ValidationPatterns; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Process\FakeProcessResult; use Illuminate\Support\Facades\Process; use Livewire\Attributes\Locked; use Livewire\Livewire; @@ -92,9 +91,10 @@ describe('GetLogs Livewire action validation', function () { ->assertSee('All') ->assertSeeHtml('title="Show all logs"') ->call('showAllLogs') - ->assertSet('numberOfLines', -1); + ->assertSet('numberOfLines', -1) + ->assertReturned('all logs'); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker logs -n all')); + Process::assertRan(fn ($process) => str_contains($process->command, 'docker logs -n all -t test-container')); }); test('getLogs marks ANSI-colored output truncated based on raw bytes', function () { @@ -108,23 +108,15 @@ describe('GetLogs Livewire action validation', function () { expect(strlen($output))->toBe(GetLogs::MAX_DISPLAY_SIZE_BYTES + 1); - Process::shouldReceive('timeout')->once()->andReturnSelf(); - Process::shouldReceive('run')->andReturnUsing(function (string $command, ?callable $callback = null) use ($output): FakeProcessResult { - if ($callback) { - $callback('out', $output); - } - - return new FakeProcessResult(command: $command); - }); + Process::fake(['*' => Process::result(output: $output)]); $component = new GetLogs; $component->server = $server; $component->resource = $this->application; $component->container = 'test-container'; $component->showTimeStamps = false; - $component->getLogs(true); - expect($component->outputs) + expect($component->getLogs()) ->toContain('[... Output truncated at 5MB limit ...]') ->not->toContain("\e[31m"); }); @@ -145,7 +137,7 @@ describe('GetLogs Livewire action validation', function () { 'container' => 'container;malicious-command', ]) ->call('getLogs') - ->assertSet('outputs', 'Invalid container name.'); + ->assertReturned('Invalid container name.'); }); test('getLogs rejects unauthorized server access', function () { @@ -158,7 +150,7 @@ describe('GetLogs Livewire action validation', function () { 'container' => 'test-container', ]) ->call('getLogs') - ->assertSet('outputs', 'Unauthorized.'); + ->assertReturned('Unauthorized.'); }); test('downloadAllLogs returns empty for invalid container name', function () { @@ -192,17 +184,117 @@ describe('GetLogs Livewire action validation', function () { }); }); -describe('GetLogs stream polling', function () { - test('streaming logs polls when log panel is not collapsible', function () { +describe('GetLogs browser-rendered output', function () { + beforeEach(function () { + $this->server->settings->fill([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ])->save(); + $this->functionalServer = Server::with('settings')->findOrFail($this->server->id); + }); + + test('getLogs returns timestamped output without storing it in component state', function () { + Process::fake(['*' => Process::result(output: "2026-09-28T10:00:02Z second\n2026-09-28T10:00:01Z first")]); + + $component = Livewire::test(GetLogs::class, [ + 'server' => $this->functionalServer, + 'resource' => $this->application, + 'container' => 'test-container', + ]) + ->set('showTimeStamps', false) + ->call('getLogs') + ->assertReturned("2026-09-28T10:00:01Z first\n2026-09-28T10:00:02Z second"); + + expect($component->instance())->not->toHaveProperty('outputs'); + Process::assertRan(fn ($process) => str_contains($process->command, 'docker logs -n 100 -t test-container')); + }); + + test('getLogs only requests new lines when streaming from a timestamp', function () { + Process::fake(['*' => Process::result(output: '2026-09-28T10:00:03Z new')]); + Livewire::test(GetLogs::class, [ - 'server' => $this->server, + 'server' => $this->functionalServer, + 'resource' => $this->application, + 'container' => 'test-container', + ]) + ->call('getLogs', '2026-09-28T10:00:02.123456789Z') + ->assertReturned('2026-09-28T10:00:03Z new'); + + Process::assertRan(fn ($process) => str_contains($process->command, 'docker logs --since 2026-09-28T10:00:02.123456789Z -t test-container')); + }); + + test('getLogs prefixes the streaming command with sudo for non-root servers', function () { + $this->functionalServer->update(['user' => 'ubuntu']); + Process::fake(['*' => Process::result(output: '')]); + + Livewire::test(GetLogs::class, [ + 'server' => $this->functionalServer->fresh(['settings']), + 'resource' => $this->application, + 'container' => 'test-container', + ])->call('getLogs', '2026-09-28T10:00:02Z'); + + Process::assertRan(fn ($process) => str_contains($process->command, '(sudo docker logs --since 2026-09-28T10:00:02Z -t test-container) 2>&1 | head -c')); + }); + + test('getLogs ignores a since value that is not a docker timestamp', function (string $since) { + Process::fake(['*' => Process::result(output: 'logs')]); + + Livewire::test(GetLogs::class, [ + 'server' => $this->functionalServer, + 'resource' => $this->application, + 'container' => 'test-container', + ])->call('getLogs', $since); + + Process::assertRan(fn ($process) => str_contains($process->command, 'docker logs -n 100 -t test-container') + && ! str_contains($process->command, '--since')); + })->with([ + 'command substitution' => ['2026-09-28T10:00:02Z$(id)'], + 'shell separator' => ['2026-09-28T10:00:02Z; id'], + 'relative duration' => ['10m'], + ]); + + test('copyLogs keeps line order and omits timestamps when timestamps are hidden', function () { + Process::fake(['*' => Process::result(output: "zeta\nalpha")]); + + Livewire::test(GetLogs::class, [ + 'server' => $this->functionalServer, + 'resource' => $this->application, + 'container' => 'test-container', + ]) + ->set('showTimeStamps', false) + ->call('copyLogs') + ->assertReturned("zeta\nalpha"); + + Process::assertRan(fn ($process) => str_contains($process->command, 'docker logs -n 100 test-container')); + }); + + test('copyLogs returns empty for unauthorized server', function () { + $otherServer = Server::factory()->create(['team_id' => Team::factory()->create()->id]); + + Livewire::test(GetLogs::class, [ + 'server' => $otherServer, + 'resource' => $this->application, + 'container' => 'test-container', + ]) + ->call('copyLogs') + ->assertReturned(''); + }); + + test('streaming does not render log lines or poll through Livewire', function () { + Process::fake(['*' => Process::result(output: '2026-09-28T10:00:01Z secret-looking line')]); + + Livewire::test(GetLogs::class, [ + 'server' => $this->functionalServer, 'resource' => $this->application, 'container' => 'coolify-sentinel', 'collapsible' => false, ]) - ->assertDontSeeHtml('wire:poll.2000ms="getLogs(true)"') ->call('toggleStreamLogs') - ->assertSeeHtml('wire:poll.2000ms="getLogs(true)"'); + ->assertSet('streamLogs', true) + ->call('getLogs') + ->assertDontSeeHtml('wire:poll') + ->assertDontSee('secret-looking line'); }); }); diff --git a/tests/Feature/StableNestedLivewireKeysTest.php b/tests/Feature/StableNestedLivewireKeysTest.php index 56a21bf659..a90afd6bef 100644 --- a/tests/Feature/StableNestedLivewireKeysTest.php +++ b/tests/Feature/StableNestedLivewireKeysTest.php @@ -46,12 +46,15 @@ it('keys nested Livewire status components rendered inside navigation loops', fu ->toContain(':key="\'application-server-status-\'.$application->uuid"'); }); -it('keys rolling log lines by content occurrence instead of list position', function () { - $logs = file_get_contents(resource_path('views/livewire/project/shared/get-logs.blade.php')); +it('keys virtual log rows by stable line identity instead of list position', function () { + $view = file_get_contents(resource_path('views/livewire/project/shared/get-logs.blade.php')); + $script = file_get_contents(resource_path('js/runtime-logs.js')); - expect($logs) - ->toContain('$lineOccurrences = [];') - ->toContain('$lineFingerprint = md5($line);') - ->toContain('wire:key="log-{{ $lineFingerprint }}-{{ $lineOccurrence }}"') - ->not->toContain("'line-' . \$index"); + expect($view) + ->toContain(':key="row.key"') + ->not->toContain(':key="row.index"'); + + expect($script) + ->toContain('getItemKey: (index) => filtered[index]?.id ?? index') + ->toContain('id: nextId()'); }); diff --git a/tests/Unit/LogViewerXssSecurityTest.php b/tests/Unit/LogViewerXssSecurityTest.php index e3230c1fdd..3067f24cd1 100644 --- a/tests/Unit/LogViewerXssSecurityTest.php +++ b/tests/Unit/LogViewerXssSecurityTest.php @@ -46,19 +46,27 @@ describe('Log Viewer XSS Prevention', function () { }); it('uses text nodes for search highlighting instead of injected html', function () { - $views = [ - __DIR__.'/../../resources/views/livewire/project/application/deployment/show.blade.php', - __DIR__.'/../../resources/views/livewire/project/shared/get-logs.blade.php', - ]; + $deploymentView = file_get_contents(__DIR__.'/../../resources/views/livewire/project/application/deployment/show.blade.php'); - foreach ($views as $view) { - $contents = file_get_contents($view); + expect($deploymentView) + ->toContain('document.createTextNode') + ->toContain('mark.textContent') + ->not->toContain('innerHTML') + ->not->toContain('x-html'); + }); - expect($contents) - ->toContain('document.createTextNode') - ->toContain('mark.textContent') - ->not->toContain('innerHTML') - ->not->toContain('x-html'); - } + it('renders runtime log text and highlight segments with x-text only', function () { + $runtimeView = file_get_contents(__DIR__.'/../../resources/views/livewire/project/shared/get-logs.blade.php'); + $runtimeScript = file_get_contents(__DIR__.'/../../resources/js/runtime-logs.js'); + + expect($runtimeView) + ->toContain('x-text="segment.text"') + ->toContain('x-text="formatLogDetails(row.line.text)"') + ->not->toContain('innerHTML') + ->not->toContain('x-html'); + + expect($runtimeScript) + ->not->toContain('innerHTML') + ->not->toContain('insertAdjacentHTML'); }); }); diff --git a/tests/v4/Browser/RuntimeLogsVirtualizationTest.php b/tests/v4/Browser/RuntimeLogsVirtualizationTest.php new file mode 100644 index 0000000000..8b849859e2 --- /dev/null +++ b/tests/v4/Browser/RuntimeLogsVirtualizationTest.php @@ -0,0 +1,126 @@ +set('app.maintenance.store', 'array'); + $this->stack = seedBrowserResourceStack(); + + // 50,000 timestamped lines, the maximum the viewer accepts. + $this->bigLog = implode("\n", array_map( + fn (int $i) => sprintf('2026-09-28T10:%02d:%02d.%09dZ log line %05d', intdiv($i, 3600) % 60, intdiv($i, 60) % 60, $i, $i), + range(1, 50000), + )); + + Process::fake(function (PendingProcess $process) { + if (str_contains($process->command, '--since')) { + return Process::result(output: '2026-09-28T23:59:59Z streamed line after since'); + } + if (str_contains($process->command, 'docker logs')) { + return Process::result(output: $this->bigLog); + } + + return Process::result(); + }); +}); + +it('renders 50,000 runtime log lines with only the visible rows in the DOM', function () { + $page = visit('/login') + ->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertSee('Dashboard') + ->navigate("/server/{$this->stack['server']->uuid}/sentinel/logs"); + + $page->assertSee('log line 00001'); + + expect($page->script('document.querySelectorAll("[data-log-line]").length'))->toBeLessThan(100); + + // Scroll to the end: the last line is rendered, the first line is not. + $page->script('(() => { const el = document.getElementById("logsContainer"); el.scrollTop = el.scrollHeight; })()'); + $page->wait(0.5) + ->assertSee('log line 50000') + ->assertDontSee('log line 00001'); + + expect($page->script('document.querySelectorAll("[data-log-line]").length'))->toBeLessThan(100); + + $page->screenshot(filename: 'runtime-logs-virtualized-end'); +}); + +it('searches all loaded lines and highlights matches', function () { + $page = visit('/login') + ->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertSee('Dashboard') + ->navigate("/server/{$this->stack['server']->uuid}/sentinel/logs"); + + $page->assertSee('log line 00001') + ->type('input[aria-label="Find in logs"]', 'line 4999') + ->wait(0.6) + ->assertSee('log line 49990') + ->assertDontSee('log line 00001'); + + expect($page->script('document.querySelectorAll(".log-highlight").length'))->toBe(10) + ->and($page->script('document.querySelector(".log-highlight").textContent'))->toBe('line 4999'); + + // Row details still open inside the virtual list. + $page->click('log line 49990') + ->wait(0.3); + + expect($page->script('document.querySelectorAll(".runtime-log-detail").length'))->toBe(1) + ->and($page->script('document.querySelector(".runtime-log-detail").textContent'))->toBe('log line 49990'); + + $page->screenshot(filename: 'runtime-logs-virtualized-search'); +}); + +it('moves ctrl+f to the log search and lets a second ctrl+f reach the browser', function () { + $page = visit('/login') + ->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertSee('Dashboard') + ->navigate("/server/{$this->stack['server']->uuid}/sentinel/logs"); + + $page->assertSee('log line 00001') + ->assertSee('Ctrl+F') + ->script('document.activeElement.blur()'); + + $page->keys('body', 'Control+f'); + + expect($page->script('document.activeElement.getAttribute("aria-label")'))->toBe('Find in logs'); + + // In the search field, Ctrl+F is not intercepted, so the browser find can open. + expect($page->script('(() => { + const event = new KeyboardEvent("keydown", { key: "f", ctrlKey: true, bubbles: true, cancelable: true }); + document.activeElement.dispatchEvent(event); + return event.defaultPrevented; + })()'))->toBeFalse(); + + $page->screenshot(filename: 'runtime-logs-find-shortcut'); +}); + +it('streams only new lines and appends them to the loaded log', function () { + $page = visit('/login') + ->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertSee('Dashboard') + ->navigate("/server/{$this->stack['server']->uuid}/sentinel/logs"); + + $page->assertSee('log line 00001') + ->click('[title="Stream Logs"]') + ->wait(3) + ->click('[title="Follow Logs"]') + ->wait(0.5) + ->assertSee('streamed line after since') + ->assertSee('log line 50000'); + + Process::assertRan(fn ($process) => str_contains($process->command, 'docker logs --since 2026-09-28T10:13:53.000050000Z -t coolify-sentinel')); + + $page->screenshot(filename: 'runtime-logs-virtualized-stream'); +});