mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
feat(logs): render runtime logs in a virtualized list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d59bcf5ae3
commit
e778d99e9d
12 files changed
+1064
-567
No files matched your search
@@ -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
|
||||
|
||||
Generated
+11
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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()');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user