feat(logs): render runtime logs in a virtualized list

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras BacsaiandClaude Opus 5.5 committed 2026-09-28 11:09:13 +02:00
1 parent d59bcf5ae3
commit e778d99e9d
12 files changed
+1064 -567

No files matched your search

+84 -151
View File
@@ -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
+11
View File
@@ -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",
+1
View File
@@ -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",
+85 -2
View File
@@ -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 {
+2
View File
@@ -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);
/**
+449
View File
@@ -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);
},
};
});
}
+98
View File
@@ -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('<b>Error</b> error', 'error'), [
{ text: '<b>', match: false },
{ text: 'Error', match: true },
{ text: '</b> ', 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);
});
@@ -1,295 +1,14 @@
<div @class(['w-full min-w-0', 'runtime-log-shell' => $collapsible])>
<div id="screen" x-data="{
collapsible: {{ $collapsible ? 'true' : 'false' }},
expanded: {{ ($expandByDefault || !$collapsible) ? 'true' : 'false' }},
logsLoaded: false,
fullscreen: false,
alwaysScroll: false,
followManuallyDisabled: false,
rafId: null,
scrollTimeout: null,
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: '',
matchCount: 0,
expandedLogs: {},
containerName: '{{ $container ?? "logs" }}',
makeFullscreen() {
this.fullscreen = !this.fullscreen;
if (this.fullscreen === false) {
this.alwaysScroll = false;
this.cancelScrollLoop();
}
},
handleKeyDown(event) {
if (event.key === 'Escape' && this.fullscreen) {
this.makeFullscreen();
}
},
isScrolling: false,
lastTouchY: 0,
disableFollow() {
if (!this.alwaysScroll) return;
this.alwaysScroll = false;
this.cancelScrollLoop();
},
cancelScrollLoop() {
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (this.scrollTimeout) {
clearTimeout(this.scrollTimeout);
this.scrollTimeout = null;
}
if (this.scrollDebounce) {
clearTimeout(this.scrollDebounce);
this.scrollDebounce = null;
}
},
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) 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)">
<div id="screen" x-data="runtimeLogs(@js([
'collapsible' => $collapsible,
'expanded' => $expandByDefault || ! $collapsible,
'containerName' => $container ?? 'logs',
'timezone' => getServerTimezone($server),
]))" @keydown.window="handleKeyDown($event)"
:data-runtime-logs="fullscreen ? 'fullscreen' : (expanded ? 'expanded' : 'collapsed')">
@if ($collapsible)
<div class="runtime-log-trigger"
x-on:click="expanded = !expanded; if (expanded && !logsLoaded) { $wire.getLogs(true); logsLoaded = true; }">
x-on:click="expanded = !expanded; if (expanded && !logsLoaded) { refresh(); logsLoaded = true; }">
<svg class="w-4 h-4 transition-transform" :class="expanded ? 'rotate-90' : ''" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z" />
@@ -309,9 +28,6 @@
@endif
</div>
@endif
@if ($streamLogs)
<div class="sr-only" wire:poll.2000ms="getLogs(true)" aria-hidden="true"></div>
@endif
<div x-show="expanded" {{ $collapsible ? 'x-collapse.duration.200ms' : '' }}
:class="fullscreen ? 'fullscreen flex flex-col !overflow-visible' : 'relative w-full mx-auto'"
:style="fullscreen ? 'max-height: none !important; height: 100% !important;' : ''">
@@ -320,7 +36,7 @@
<div class="runtime-log-toolbar logs-viewer-toolbar">
<div class="logs-viewer-toolbar-controls">
<div class="logs-viewer-actions">
<button wire:click="getLogs(true)" title="Refresh Logs" {{ $streamLogs ? 'disabled' : '' }}
<button x-on:click="refresh()" title="Refresh Logs" {{ $streamLogs ? 'disabled' : '' }}
class="runtime-log-icon-button order-8">
<x-reicon name="refresh" class="size-3.5" />
</button>
@@ -499,23 +215,25 @@
</div>
<div class="logs-viewer-end runtime-logs-viewer-end">
<div class="logs-viewer-meta">
<form wire:submit="getLogs(true)" class="logs-viewer-lines">
<form x-on:submit.prevent="refresh()" class="logs-viewer-lines runtime-log-lines-group">
<span class="logs-viewer-lines-label">Lines</span>
<input type="number" wire:model="numberOfLines" placeholder="100" min="-1" max="50000"
title="Number of lines (max 50,000; use -1 for all)" {{ $streamLogs ? 'readonly' : '' }}
class="input logs-viewer-lines-input" />
<button type="button" wire:click="showAllLogs" title="Show all logs"
class="runtime-log-icon-button" {{ $streamLogs ? 'disabled' : '' }}>All</button>
<button type="button" x-on:click="showAllLogs()" title="Show all logs"
:class="$wire.numberOfLines == -1 ? 'runtime-log-lines-all-active' : ''"
:aria-pressed="$wire.numberOfLines == -1"
class="runtime-log-lines-all" {{ $streamLogs ? 'disabled' : '' }}>All</button>
</form>
<span x-show="searchQuery.trim()" x-text="matchCount + ' matches'"
class="text-xs text-gray-500 whitespace-nowrap dark:text-gray-400"></span>
</div>
<div class="logs-viewer-search relative">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input type="search" x-model.debounce.300ms="searchQuery" placeholder="Find in logs"
aria-label="Find in logs"
<input type="search" x-ref="search" x-model.debounce.300ms="searchQuery" placeholder="Find in logs"
aria-label="Find in logs" :aria-keyshortcuts="findShortcutLabel === '⌘F' ? 'Meta+F' : 'Control+F'"
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint" />
<kbd x-cloak x-show="!searchQuery" x-text="findShortcutLabel" aria-hidden="true"
class="pointer-events-none absolute top-1/2 right-2 z-10 -translate-y-1/2 rounded border border-neutral-200 px-1 font-sans text-[10px] leading-4 text-neutral-400 dark:border-white/[0.08] dark:text-fg-faint"></kbd>
<button x-cloak x-show="searchQuery" x-on:click="searchQuery = ''" type="button"
class="absolute top-1/2 right-2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
aria-label="Clear search">
@@ -525,86 +243,59 @@
</div>
</div>
</div>
<div id="logsContainer" @scroll="handleScroll" @wheel="handleWheel"
<div id="logsContainer" x-ref="viewport" @scroll="handleScroll" @wheel="handleWheel"
@touchstart="handleTouchStart" @touchmove="handleTouchMove" @keydown="handleKeyScroll" tabindex="0"
class="runtime-log-viewport logs-viewer-viewport flex min-w-0 w-full flex-col overflow-x-hidden overflow-y-auto scrollbar"
class="runtime-log-viewport logs-viewer-viewport relative flex min-w-0 w-full flex-col overflow-x-hidden overflow-y-auto scrollbar"
:class="fullscreen ? 'flex-1' : 'max-h-[min(40rem,70dvh)] sm:max-h-[40rem]'">
@if ($outputs)
@php
$displayLines = collect(explode("\n", $outputs))->filter(fn($line) => trim($line) !== '');
$lineOccurrences = [];
@endphp
<div id="logs" @class([
'font-logs max-w-full cursor-default text-[11px] leading-relaxed sm:text-xs',
'runtime-log-without-time' => !$showTimeStamps,
])>
<div class="runtime-log-columns" aria-hidden="true">
@if ($showTimeStamps)
<span>Time</span>
@endif
<span>Type</span>
<span>Message</span>
</div>
<div x-show="searchQuery.trim() && matchCount === 0"
class="py-2 text-gray-500 dark:text-gray-400">
No matches found.
</div>
@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)
<div wire:key="log-{{ $lineFingerprint }}-{{ $lineOccurrence }}" data-log-line data-log-content="{{ $line }}"
role="button" tabindex="0"
:aria-expanded="isLogExpanded(@js($lineKey))"
x-on:click="toggleLogDetails(@js($lineKey), $event)"
x-on:keydown="toggleLogDetails(@js($lineKey), $event)"
class="log-line logs-viewer-line">
@if ($timestamp && $showTimeStamps)
<span class="logs-viewer-timestamp text-gray-500">{{ $timestamp }}</span>
@endif
<span data-line-text="{{ $logContent }}" class="logs-viewer-line-text">{{ $logContent }}</span>
<div class="runtime-log-loading" x-show="loading && lineCount === 0" role="status">
<x-loading compact aria-label="Loading logs" />
<span>Loading logs</span>
</div>
<div class="runtime-log-empty" x-show="!loading && lineCount === 0" role="status">
<span class="runtime-log-empty-icon" aria-hidden="true">
<x-reicon name="terminal" class="size-4" />
</span>
<div>
<p>No logs yet</p>
<span>Logs will appear here when the container produces output.</span>
</div>
</div>
{{-- Alpine renders only the visible rows. Livewire must not morph them. --}}
<div id="logs" wire:ignore x-cloak x-show="lineCount > 0"
:class="{ 'runtime-log-without-time': !$wire.showTimeStamps }"
class="font-logs max-w-full cursor-default text-[11px] leading-relaxed sm:text-xs">
<div x-ref="columns" class="runtime-log-columns" aria-hidden="true">
<span x-show="$wire.showTimeStamps">Time</span>
<span>Type</span>
<span>Message</span>
</div>
<div x-ref="list" class="runtime-log-virtual-list" :style="{ height: totalSize + 'px' }">
<template x-for="row in virtualRows" :key="row.key">
<div class="runtime-log-virtual-row" :data-index="row.index"
:style="{ transform: 'translateY(' + (row.start - scrollMargin) + 'px)' }"
x-init="$nextTick(() => measureRow($el))">
<div data-log-line role="button" tabindex="0" :data-log-level="row.line.level"
:class="colorLogs ? 'log-' + row.line.level : ''"
:aria-expanded="isLogExpanded(row.line.id)"
x-on:click="toggleLogDetails(row.line.id, $event)"
x-on:keydown="toggleLogDetails(row.line.id, $event)"
class="log-line logs-viewer-line">
<template x-if="$wire.showTimeStamps && row.line.ts">
<span class="logs-viewer-timestamp text-gray-500" x-text="formatTimestamp(row.line.ts)"></span>
</template>
<span data-line-text class="logs-viewer-line-text"><template x-for="(segment, segmentIndex) in highlight(row.line.text)" :key="segmentIndex"><span :class="segment.match ? 'log-highlight' : ''" x-text="segment.text"></span></template></span>
</div>
<template x-if="isLogExpanded(row.line.id)">
<pre class="runtime-log-detail" aria-label="Full log entry"
x-text="formatLogDetails(row.line.text)"></pre>
</template>
</div>
<pre x-cloak x-show="isLogExpanded(@js($lineKey))"
class="runtime-log-detail"
aria-label="Full log entry"
x-text="formatLogDetails(@js($logContent))"></pre>
@endforeach
</template>
</div>
@else
<div class="runtime-log-loading" wire:loading.flex wire:target="getLogs" role="status">
<x-loading compact aria-label="Loading logs" />
<span>Loading logs</span>
<div x-show="appliedQuery && matchCount === 0" class="px-4 py-2 text-gray-500 dark:text-gray-400">
No matches found.
</div>
<div id="logs" class="runtime-log-empty" wire:loading.remove wire:target="getLogs" role="status">
<span class="runtime-log-empty-icon" aria-hidden="true">
<x-reicon name="terminal" class="size-4" />
</span>
<div>
<p>No logs yet</p>
<span>Logs will appear here when the container produces output.</span>
</div>
</div>
@endif
</div>
</div>
</div>
</div>
+112 -20
View File
@@ -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');
});
});
+10 -7
View File
@@ -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()');
});
+20 -12
View File
@@ -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');
});
});
@@ -0,0 +1,126 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Process\PendingProcess;
use Illuminate\Support\Facades\Process;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->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');
});