From 2e7cf8633b2555e4f32bf6379feb840a96fc0e34 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:55:44 +0200 Subject: [PATCH] feat(logs): pick runtime log streams from service selectors Replace the container accordion with Deployment and Service selectors that show one stream, merging replica containers by timestamp. The viewer fills the page, opens in text view, keeps whitespace, and only lets grid rows expand when the detail shows more than the row. Based on the proposal in discussion #12176 by PhilippMolitor. --- app/Livewire/Project/Shared/GetLogs.php | 318 +++++++++++-- app/Livewire/Project/Shared/Logs.php | 88 +++- app/Support/RuntimeLogTargets.php | 436 ++++++++++++++++++ resources/css/app.css | 180 +++++++- resources/js/runtime-logs.js | 189 ++++++-- resources/js/runtime-logs.test.js | 49 ++ .../views/components/forms/listbox.blade.php | 59 ++- .../project/shared/get-logs.blade.php | 22 +- .../livewire/project/shared/logs.blade.php | 68 ++- tests/Feature/GetLogsMergedSourcesTest.php | 186 ++++++++ .../RuntimeLogsContainerRefreshTest.php | 100 ++++ tests/Unit/Support/RuntimeLogTargetsTest.php | 250 ++++++++++ .../v4/Browser/RuntimeLogsServiceMenuTest.php | 74 +++ .../Browser/RuntimeLogsVirtualizationTest.php | 130 +++++- 14 files changed, 1995 insertions(+), 154 deletions(-) create mode 100644 app/Support/RuntimeLogTargets.php create mode 100644 tests/Feature/GetLogsMergedSourcesTest.php create mode 100644 tests/Unit/Support/RuntimeLogTargetsTest.php create mode 100644 tests/v4/Browser/RuntimeLogsServiceMenuTest.php diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index f9b15afac4..cabf0bc469 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -20,6 +20,7 @@ use App\Models\StandaloneRedis; use App\Models\StandaloneSqlite; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Process; use Livewire\Attributes\Locked; use Livewire\Attributes\Renderless; @@ -52,6 +53,14 @@ class GetLogs extends Component #[Locked] public ?string $container = null; + /** + * Containers of one service. Empty keeps the single-container log command. + * + * @var list + */ + #[Locked] + public array $sources = []; + public ?string $displayName = null; public ?string $pull_request = null; @@ -66,6 +75,8 @@ class GetLogs extends Component public bool $collapsible = true; + public bool $fill = false; + public function mount() { if (! is_null($this->resource)) { @@ -149,24 +160,17 @@ class GetLogs extends Component * holds a Docker timestamp, only lines from that moment onward are returned. */ #[Renderless] - public function getLogs(?string $since = null): string + public function getLogs(mixed $since = null): string { - if (! $this->canReadLogs()) { - return 'Unauthorized.'; - } - if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) { - return 'Unauthorized.'; - } - if (! $this->server->isFunctional()) { - return ''; - } - if ($this->container && ! ValidationPatterns::isValidContainerName($this->container)) { - return 'Invalid container name.'; - } - if (! $this->container) { - return ''; + if ($this->sources !== []) { + return $this->mergedLogs(is_array($since) ? $since : null, self::MAX_DISPLAY_SIZE_BYTES, '5MB', export: false); } + if (! $this->canReadSingleContainer()) { + return $this->singleContainerDenial(); + } + + $since = is_string($since) ? $since : null; if (is_string($since) && preg_match(self::DOCKER_TIMESTAMP_PATTERN, $since)) { $options = "--since {$since} -t"; } else { @@ -178,16 +182,13 @@ class GetLogs extends Component public function copyLogs(): string { - if (! $this->canReadLogs()) { - return ''; + if ($this->sources !== []) { + return sanitizeLogsForExport( + $this->mergedLogs(null, self::MAX_DISPLAY_SIZE_BYTES, '5MB', export: false, stripTimestamps: ! $this->showTimeStamps), + $this->logRedactionSecrets(), + ); } - if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) { - return ''; - } - if (! $this->server->isFunctional() || ! $this->container) { - return ''; - } - if (! ValidationPatterns::isValidContainerName($this->container)) { + if (! $this->canReadSingleContainer()) { return ''; } @@ -198,16 +199,10 @@ class GetLogs extends Component public function downloadAllLogs(): string { - if (! $this->canReadLogs()) { - return ''; + if ($this->sources !== []) { + return sanitizeLogsForExport($this->mergedLogs(null, self::MAX_DOWNLOAD_SIZE_BYTES, '50MB', export: true, all: true), $this->logRedactionSecrets()); } - if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) { - return ''; - } - if (! $this->server->isFunctional() || ! $this->container) { - return ''; - } - if (! ValidationPatterns::isValidContainerName($this->container)) { + if (! $this->canReadSingleContainer()) { return ''; } @@ -216,6 +211,39 @@ class GetLogs extends Component return sanitizeLogsForExport($this->runLogCommand($command, self::MAX_DOWNLOAD_SIZE_BYTES, '50MB', $this->showTimeStamps), $this->logRedactionSecrets()); } + /** + * One container keeps the previous denial text. An invalid name is distinct from a missing permission. + */ + private function singleContainerDenial(): string + { + if (! $this->canReadLogs() || ! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) { + return 'Unauthorized.'; + } + if (! $this->server->isFunctional() || ! $this->container) { + return ''; + } + if (! ValidationPatterns::isValidContainerName($this->container)) { + return 'Invalid container name.'; + } + + return ''; + } + + private function canReadSingleContainer(): bool + { + if (! $this->canReadLogs()) { + return false; + } + if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) { + return false; + } + if (! $this->server->isFunctional() || ! $this->container) { + return false; + } + + return ValidationPatterns::isValidContainerName($this->container); + } + /** * Environment values and database credentials of the resource whose logs are exported. * @@ -228,6 +256,191 @@ class GetLogs extends Component return EnvironmentVariable::logRedactionSecretsFor($resource, includePreview: $resource instanceof Application && filled($this->pull_request)); } + /** + * Read every container of one service and return one timestamp-ordered stream. + * A source that is not one of this resource's servers, or whose container name + * is not safe to pass to the shell, rejects the whole read before any command runs. + * + * @param array|null $since + */ + private function mergedLogs(?array $since, int $maxBytes, string $limitLabel, bool $export, bool $all = false, ?bool $stripTimestamps = null): string + { + $sources = $this->authorizedSources(); + if ($sources === null) { + return $export ? '' : 'Unauthorized.'; + } + if ($sources === []) { + return ''; + } + + $tail = $this->resolveLogTail(); + $groups = []; + $truncated = false; + foreach ($sources as $source) { + $sinceValue = $this->dockerSince($since, $source['label']); + if ($all) { + $options = '-t'; + } elseif ($sinceValue !== null) { + $options = "--since {$sinceValue} -t"; + } else { + $options = "-n {$tail} -t"; + } + $fetched = $this->executeLogCommand( + $source['server'], + $this->logCommand($options, $source['server'], $source['container']), + $maxBytes, + ); + $truncated = $truncated || $fetched['truncated']; + $groups[] = ['label' => $source['label'], 'logs' => $fetched['logs']]; + } + + $stripTimestamps ??= $export && ! $this->showTimeStamps; + $merged = $this->joinMergedLines($groups, $stripTimestamps, $export); + if (strlen($merged) > $maxBytes) { + $merged = rtrim(substr($merged, 0, $maxBytes), "\n"); + $truncated = true; + } + if ($truncated && $merged !== '') { + $merged .= "\n\n[... Output truncated at {$limitLabel} limit ...]"; + } + + return $merged; + } + + /** + * @param array|null $since + */ + private function dockerSince(?array $since, string $label): ?string + { + $value = $since[$label] ?? null; + if (! is_string($value) || preg_match(self::DOCKER_TIMESTAMP_PATTERN, $value) !== 1) { + return null; + } + + return $value; + } + + /** + * @param list $groups + */ + private function joinMergedLines(array $groups, bool $stripTimestamp, bool $export): string + { + $rows = []; + foreach ($groups as $group) { + if ($group['logs'] === '') { + continue; + } + foreach (explode("\n", $group['logs']) as $line) { + if (trim($line) === '') { + continue; + } + $rows[] = [ + 'timestamp' => explode(' ', $line, 2)[0], + 'label' => $group['label'], + 'line' => $line, + ]; + } + } + + usort($rows, function (array $left, array $right): int { + $byTime = $left['timestamp'] <=> $right['timestamp']; + + return $byTime !== 0 ? $byTime : strcmp($left['label'], $right['label']); + }); + + $lines = []; + foreach ($rows as $row) { + $text = $stripTimestamp ? $this->stripDockerTimestamp($row['line']) : $row['line']; + $lines[] = $export ? $row['label'].' | '.$text : $row['label']."\t".$text; + } + + return implode("\n", $lines); + } + + private function stripDockerTimestamp(string $line): string + { + if (preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z (.*)$/s', $line, $matches) === 1) { + return $matches[1]; + } + + return $line; + } + + /** + * @return list|null + */ + private function authorizedSources(): ?array + { + if (! $this->canReadLogs() || $this->resource === null) { + return null; + } + + $teamId = $this->resourceTeamId(); + if ($teamId === null) { + return null; + } + + $allowed = $this->serversForResource()->keyBy(fn (Server $server): string => (string) $server->uuid); + $resolved = []; + $labels = []; + foreach ($this->sources as $source) { + if (! is_array($source)) { + return null; + } + $uuid = (string) ($source['server'] ?? ''); + $container = (string) ($source['container'] ?? ''); + $label = (string) ($source['label'] ?? ''); + if ($label === '' || str_contains($label, "\t") || str_contains($label, "\n") || isset($labels[$label])) { + return null; + } + $server = $allowed->get($uuid); + if (! $server instanceof Server || (int) $server->team_id !== $teamId) { + return null; + } + if (! ValidationPatterns::isValidContainerName($container)) { + return null; + } + if (! $server->isFunctional()) { + continue; + } + $labels[$label] = true; + $resolved[] = [ + 'server' => $server, + 'container' => $container, + 'label' => $label, + ]; + } + + return $resolved; + } + + private function resourceTeamId(): ?int + { + if (! is_object($this->resource) || ! method_exists($this->resource, 'team')) { + return null; + } + $team = $this->resource->team(); + $id = is_object($team) ? ($team->id ?? null) : null; + + return is_numeric($id) ? (int) $id : null; + } + + private function serversForResource(): Collection + { + $resource = $this->resource; + if ($resource instanceof Application) { + return $resource->maintenanceServers(); + } + if ($resource instanceof Service) { + return collect([$resource->server])->filter(); + } + if (is_object($resource) && method_exists($resource, 'destination')) { + return collect([$resource->destination?->server])->filter(); + } + + return collect(); + } + private function resolveLogTail(): int|string { if ($this->numberOfLines === -1) { @@ -243,16 +456,18 @@ class GetLogs extends Component return $this->numberOfLines; } - private function logCommand(string $options): string + private function logCommand(string $options, ?Server $server = null, ?string $container = null): string { + $server ??= $this->server; + $container ??= (string) $this->container; $options = trim($options); $options = $options === '' ? '' : "{$options} "; - $command = $this->server->isSwarm() - ? "docker service logs {$options}{$this->container}" - : "docker logs {$options}{$this->container}"; + $command = $server->isSwarm() + ? "docker service logs {$options}{$container}" + : "docker logs {$options}{$container}"; - if ($this->server->isNonRoot()) { - $command = parseCommandsByLineForSudo(collect($command), $this->server)[0]; + if ($server->isNonRoot()) { + $command = parseCommandsByLineForSudo(collect($command), $server)[0]; } return $command; @@ -260,12 +475,8 @@ class GetLogs extends Component private function runLogCommand(string $command, int $maxBytes, string $limitLabel, bool $timestamped = true): string { - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $this->boundedLogCommand($command, $maxBytes)); - - // 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"); + $fetched = $this->executeLogCommand($this->server, $command, $maxBytes); + $logs = $fetched['logs']; if ($timestamped) { // Docker writes stdout and stderr separately, so sort timestamped lines back into order. $logs = str($logs)->split('/\n/')->sort(function ($a, $b) { @@ -273,13 +484,28 @@ class GetLogs extends Component })->join("\n"); } - if ($truncated) { + if ($fetched['truncated']) { $logs .= "\n\n[... Output truncated at {$limitLabel} limit ...]"; } return $logs; } + /** + * @return array{logs: string, truncated: bool} + */ + private function executeLogCommand(Server $server, string $command, int $maxBytes): array + { + $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $this->boundedLogCommand($command, $maxBytes)); + + // 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"); + + return ['logs' => $logs, 'truncated' => $truncated]; + } + private function boundedLogCommand(string $command, int $maxBytes): string { return "({$command}) 2>&1 | head -c ".($maxBytes + 1); diff --git a/app/Livewire/Project/Shared/Logs.php b/app/Livewire/Project/Shared/Logs.php index 609d2efa3b..4c9b882dcf 100644 --- a/app/Livewire/Project/Shared/Logs.php +++ b/app/Livewire/Project/Shared/Logs.php @@ -13,8 +13,10 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Models\StandaloneSqlite; +use App\Support\RuntimeLogTargets; use App\Traits\ListensToTeamChannel; use Illuminate\Support\Collection; +use Livewire\Attributes\Url; use Livewire\Component; class Logs extends Component @@ -48,6 +50,14 @@ class Logs extends Component public bool $containersLoaded = false; + #[Url(as: 'pull_request_id', except: '0')] + public string $pullRequestId = '0'; + + #[Url(as: 'service', except: '')] + public string $service = ''; + + private bool $syncingSelection = false; + public function getListeners() { return $this->teamChannelListeners([ @@ -63,13 +73,40 @@ class Logs extends Component $this->serverContainers[$server->id] = $this->getContainersForServer($server); } $this->containersLoaded = true; + $this->syncLogSelection(); } catch (\Exception $e) { - $this->containersLoaded = true; // Set to true to stop loading spinner + $this->containersLoaded = true; + $this->syncLogSelection(); return handleError($e, $this); } } + public function updatedPullRequestId(): void + { + $this->syncLogSelection(); + } + + public function updatedService(): void + { + $this->syncLogSelection(); + } + + public function syncLogSelection(): void + { + if ($this->syncingSelection || ! $this->containersLoaded) { + return; + } + + $this->syncingSelection = true; + + $targets = $this->selectionTargets(); + $this->pullRequestId = $targets['deployment']; + $this->service = $targets['service']; + + $this->syncingSelection = false; + } + private function getContainersForServer($server) { if (! $server->isFunctional()) { @@ -169,18 +206,55 @@ class Logs extends Component } } $this->containers = $this->containers->sort(); - if (data_get($this->query, 'pull_request_id')) { - $this->containers = $this->containers->filter(function ($container) { - return str_contains($container, $this->query['pull_request_id']); - }); - } } catch (\Exception $e) { return handleError($e, $this); } } + /** + * @return array{ + * deployments: list, + * services: list, + * deployment: string, + * service: string, + * sources: list, + * streamKey: string, + * } + */ + private function selectionTargets(): array + { + return RuntimeLogTargets::select( + $this->logServerPayload(), + $this->pullRequestId, + $this->service, + $this->type === 'application', + ); + } + + /** + * @return list>}> + */ + private function logServerPayload(): array + { + if (! isset($this->servers)) { + return []; + } + + return $this->servers + ->filter(fn ($server): bool => count($this->serverContainers[$server->id] ?? []) > 0) + ->map(fn ($server): array => [ + 'uuid' => (string) $server->uuid, + 'name' => (string) $server->name, + 'containers' => array_values($this->serverContainers[$server->id] ?? []), + ]) + ->values() + ->all(); + } + public function render() { - return view('livewire.project.shared.logs'); + return view('livewire.project.shared.logs', [ + 'targets' => $this->containersLoaded ? $this->selectionTargets() : null, + ]); } } diff --git a/app/Support/RuntimeLogTargets.php b/app/Support/RuntimeLogTargets.php new file mode 100644 index 0000000000..d8454098d2 --- /dev/null +++ b/app/Support/RuntimeLogTargets.php @@ -0,0 +1,436 @@ +>}> $servers + * @return array{ + * deployments: list, + * services: list, + * deployment: string, + * service: string, + * sources: list, + * streamKey: string, + * } + */ + public static function select(array $servers, string $deployment, string $service, bool $groupDeployments): array + { + $containers = self::containers($servers, $groupDeployments); + if ($containers === []) { + return self::emptySelection(); + } + + $deploymentIds = $groupDeployments ? self::sortedDeploymentIds($containers) : []; + $chosenDeployment = $groupDeployments + ? self::pickDeployment($deploymentIds, self::normalizeDeployment($deployment)) + : '0'; + + $visible = array_values(array_filter( + $containers, + fn (array $container): bool => $container['deployment'] === $chosenDeployment, + )); + if ($visible === []) { + return self::emptySelection(); + } + + $groups = self::groupByService($visible); + $chosenKey = self::pickServiceKey($groups, $service); + $sources = self::sourcesFor($groups[$chosenKey]); + + return [ + 'deployments' => $groupDeployments ? self::deploymentOptions($deploymentIds) : [], + 'services' => self::serviceOptions($groups), + 'deployment' => $chosenDeployment, + 'service' => count($groups) > 1 ? $chosenKey : '', + 'sources' => $sources, + 'streamKey' => self::streamKey($sources), + ]; + } + + /** + * @return array{ + * deployments: list, + * services: list, + * deployment: string, + * service: string, + * sources: list, + * streamKey: string, + * } + */ + private static function emptySelection(): array + { + return [ + 'deployments' => [], + 'services' => [], + 'deployment' => '0', + 'service' => '', + 'sources' => [], + 'streamKey' => '', + ]; + } + + /** + * @param list>}> $servers + * @return list + */ + private static function containers(array $servers, bool $groupDeployments): array + { + $containers = []; + foreach (array_values($servers) as $index => $server) { + foreach ($server['containers'] ?? [] as $container) { + if (! is_array($container)) { + continue; + } + $name = (string) data_get($container, 'Names', ''); + if (! ValidationPatterns::isValidContainerName($name)) { + continue; + } + $labels = self::labels($container); + $state = strtolower(trim((string) data_get($container, 'State', 'running'))); + $containers[] = [ + 'name' => $name, + 'key' => self::serviceKey($name, $labels), + 'state' => $state !== '' ? $state : 'running', + 'deployment' => self::deploymentId($labels, $groupDeployments), + 'server' => (string) ($server['uuid'] ?? ''), + 'serverName' => (string) ($server['name'] ?? ''), + 'serverIndex' => $index, + ]; + } + } + + return $containers; + } + + /** + * @param array $container + */ + private static function labels(array $container): Collection + { + $raw = $container['Labels'] ?? null; + if (is_array($raw)) { + return collect($raw); + } + if (! is_string($raw) || trim($raw) === '') { + return collect(); + } + + $parsed = format_docker_labels_to_json($raw); + + return $parsed instanceof Collection ? $parsed : collect(); + } + + private static function serviceKey(string $containerName, Collection $labels): string + { + $compose = trim((string) $labels->get('com.docker.compose.service', '')); + if ($compose !== '') { + return $compose; + } + + $name = trim((string) $labels->get('coolify.name', '')); + if ($name !== '') { + return $name; + } + + $headline = str($containerName)->beforeLast('-')->headline()->toString(); + + return $headline !== '' ? $headline : $containerName; + } + + private static function deploymentId(Collection $labels, bool $groupDeployments): string + { + if (! $groupDeployments) { + return '0'; + } + + return self::normalizeDeployment((string) $labels->get('coolify.pullRequestId', '')); + } + + private static function normalizeDeployment(string $deployment): string + { + if ($deployment === '' || ! ctype_digit($deployment) || (int) $deployment === 0) { + return '0'; + } + + return (string) (int) $deployment; + } + + /** + * @param list $containers + * @return list + */ + private static function sortedDeploymentIds(array $containers): array + { + $ids = array_values(array_unique(array_column($containers, 'deployment'))); + usort($ids, function (string $left, string $right): int { + if ($left === '0') { + return -1; + } + if ($right === '0') { + return 1; + } + + return (int) $right <=> (int) $left; + }); + + return $ids; + } + + /** + * @param list $deploymentIds + */ + private static function pickDeployment(array $deploymentIds, string $preferred): string + { + if (in_array($preferred, $deploymentIds, true)) { + return $preferred; + } + if (in_array('0', $deploymentIds, true)) { + return '0'; + } + + return $deploymentIds[0] ?? '0'; + } + + /** + * @param list $ids + * @return list + */ + private static function deploymentOptions(array $ids): array + { + return array_map(fn (string $id): array => [ + 'value' => $id, + 'label' => $id === '0' ? 'Production' : 'PR #'.$id, + 'labelClass' => $id === '0' + ? 'deployment-badge deployment-badge-production' + : 'deployment-badge deployment-badge-preview', + ], $ids); + } + + /** + * @param list $containers + * @return array> + */ + private static function groupByService(array $containers): array + { + $groups = []; + foreach ($containers as $container) { + $groups[$container['key']][] = $container; + } + uksort($groups, fn (string $left, string $right): int => strcasecmp($left, $right)); + + return $groups; + } + + /** + * @param array> $groups + */ + private static function pickServiceKey(array $groups, string $service): string + { + if ($service !== '' && isset($groups[$service])) { + return $service; + } + + foreach ($groups as $key => $containers) { + foreach ($containers as $container) { + if ($container['state'] === 'running') { + return $key; + } + } + } + + return (string) array_key_first($groups); + } + + /** + * @param array> $groups + * @return list + */ + private static function serviceOptions(array $groups): array + { + $options = []; + foreach ($groups as $key => $containers) { + $states = array_column($containers, 'state'); + $option = [ + 'value' => $key, + 'label' => $key, + 'dot' => self::serviceDot($states), + ]; + $state = self::serviceStateLabel($states); + if ($state !== null) { + $option['state'] = $state; + } + if (count($containers) > 1) { + $option['description'] = count($containers).' containers'; + } + $options[] = $option; + } + + return $options; + } + + /** + * @param list $states + */ + private static function serviceDot(array $states): string + { + $allRunning = true; + $anyLive = false; + foreach ($states as $state) { + if ($state !== 'running') { + $allRunning = false; + } + if (in_array($state, ['running', 'starting', 'restarting', 'degraded'], true)) { + $anyLive = true; + } + } + if ($allRunning) { + return 'success'; + } + if (! $anyLive) { + return 'error'; + } + + return 'warning'; + } + + /** + * A running service needs no caption. Anything else is named: Stopped when + * nothing is running, the transitional state when that is the only problem, + * and Degraded when the containers disagree. + * + * @param list $states + */ + private static function serviceStateLabel(array $states): ?string + { + $problems = []; + $anyLive = false; + foreach ($states as $state) { + $state = strtolower($state); + if (in_array($state, ['running', 'starting', 'restarting', 'degraded'], true)) { + $anyLive = true; + } + if ($state !== 'running') { + $problems[$state] = true; + } + } + if ($problems === []) { + return null; + } + + $problemStates = array_keys($problems); + if ($problemStates === ['starting']) { + return 'Starting'; + } + if ($problemStates === ['restarting']) { + return 'Restarting'; + } + if ($problemStates === ['degraded']) { + return 'Degraded'; + } + if (! $anyLive) { + return 'Stopped'; + } + + return 'Degraded'; + } + + /** + * @param list $containers + * @return list + */ + private static function sourcesFor(array $containers): array + { + usort($containers, function (array $left, array $right): int { + $byServer = $left['serverIndex'] <=> $right['serverIndex']; + + return $byServer !== 0 ? $byServer : strcmp($left['name'], $right['name']); + }); + + $serverCounts = []; + foreach ($containers as $container) { + $serverCounts[$container['server']] = ($serverCounts[$container['server']] ?? 0) + 1; + } + $multipleServers = count($serverCounts) > 1; + $sources = []; + foreach ($containers as $container) { + $sources[] = [ + 'server' => $container['server'], + 'container' => $container['name'], + 'label' => self::sourceLabel($container, $multipleServers, $serverCounts[$container['server']], count($containers)), + ]; + } + + return self::uniqueLabels($sources); + } + + /** + * @param array{name: string, server: string, serverName: string} $container + */ + private static function sourceLabel(array $container, bool $multipleServers, int $onServer, int $total): string + { + if ($total < 2) { + return $container['name']; + } + + $parts = []; + if ($multipleServers) { + $name = trim($container['serverName']); + $parts[] = $name !== '' ? $name : $container['server']; + } + if ($onServer > 1 || ! $multipleServers) { + $parts[] = $container['name']; + } + + return implode(' ', $parts); + } + + /** + * @param list $sources + * @return list + */ + private static function uniqueLabels(array $sources): array + { + $counts = array_count_values(array_column($sources, 'label')); + foreach ($sources as $index => $source) { + if ($counts[$source['label']] < 2) { + continue; + } + if (! str_contains($source['label'], $source['container'])) { + $sources[$index]['label'] = trim($source['label'].' '.$source['container']); + } + } + + $counts = array_count_values(array_column($sources, 'label')); + foreach ($sources as $index => $source) { + if (($counts[$source['label']] ?? 0) < 2) { + continue; + } + $sources[$index]['label'] = $source['label'].' '.substr($source['server'], 0, 8); + } + + return $sources; + } + + /** + * @param list $sources + */ + private static function streamKey(array $sources): string + { + return implode('|', array_map( + fn (array $source): string => $source['server'].':'.$source['container'], + $sources, + )); + } +} diff --git a/resources/css/app.css b/resources/css/app.css index 081dd74b86..a696319686 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -4334,6 +4334,22 @@ html[data-theme="custom"] .logs-viewer-timestamp { background: transparent; } +/* Dark mode: the panel matches the cards. Rows are the card well color, and the + toolbar and column header are the darker card outer color. */ +html.dark:not([data-theme="custom"]) .runtime-log-panel { + background: var(--color-card); + box-shadow: 0 0 0 1px rgb(255 255 255 / 8%); +} + +html.dark:not([data-theme="custom"]) .runtime-log-panel :is(.runtime-log-toolbar, .runtime-log-columns) { + border-bottom-color: rgb(255 255 255 / 8%); + background: color-mix(in srgb, var(--color-card) 66%, black); +} + +html.dark:not([data-theme="custom"]) .runtime-log-panel .runtime-log-toolbar { + border-radius: 8px 8px 0 0; +} + .runtime-log-icon-button { display: inline-flex; flex-shrink: 0; @@ -4451,9 +4467,8 @@ html[data-theme="custom"] .logs-viewer-timestamp { gap: 0.625rem; box-sizing: border-box; width: 100%; - min-height: 2.75rem; - align-items: center; - padding: 0.6875rem 1.875rem 0.6875rem 1rem; + align-items: baseline; + padding: 0.125rem 1.875rem 0.125rem 1rem; } .runtime-log-columns { @@ -4480,12 +4495,16 @@ html[data-theme="custom"] .runtime-log-columns { position: relative; border-bottom: 1px solid var(--runtime-log-line); border-radius: 0; - cursor: pointer; + cursor: default; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-size: 0.8125rem; line-height: 1.6; } +.runtime-log-viewport [data-log-line][data-expandable] { + cursor: pointer; +} + .runtime-log-viewport [data-log-line]:hover, .runtime-log-viewport [data-log-line][aria-expanded="true"] { background: var(--runtime-log-hover); @@ -4504,10 +4523,14 @@ html[data-theme="custom"] .runtime-log-columns { text-transform: uppercase; } +.runtime-log-viewport [data-log-line]:not([data-expandable], [aria-expanded="true"])::after { + display: none; +} + .runtime-log-viewport [data-log-line]::after { content: ""; position: absolute; - top: 1rem; + top: 0.5rem; right: 0.75rem; width: 0.375rem; height: 0.375rem; @@ -4517,7 +4540,7 @@ html[data-theme="custom"] .runtime-log-columns { } .runtime-log-viewport [data-log-line][aria-expanded="true"]::after { - top: 1.1875rem; + top: 0.6875rem; transform: rotate(225deg); } @@ -4536,17 +4559,34 @@ html[data-theme="custom"] .runtime-log-columns { } .runtime-log-viewport [data-line-text] { + display: flex; + align-items: baseline; + gap: 0.75rem; grid-column: 3; grid-row: 1; min-width: 0; overflow: hidden; +} + +.runtime-log-message { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: pre; +} + +.runtime-log-source { + flex: 0 0 8rem; + width: 8rem; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .runtime-log-detail { - margin: 0 0 0.25rem; - padding: 1.25rem; + margin: 0; + padding: 0.5rem 1rem; border-bottom: 1px solid var(--runtime-log-line); background: var(--runtime-log-detail); color: inherit; @@ -4554,7 +4594,7 @@ html[data-theme="custom"] .runtime-log-columns { white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-size: 0.8125rem; - line-height: 1.8; + line-height: 1.6; } .dark .runtime-log-detail { @@ -4587,18 +4627,21 @@ html[data-theme="custom"] .runtime-log-columns { /* Text view: one compact, wrapped text line per log entry, like the old viewer. */ .runtime-log-text-view { - padding-block: 0.5rem; + padding-block: 0; } .runtime-log-viewport .runtime-log-text-view [data-log-line]:not(.hidden) { display: flex; - gap: 0.75rem; + flex-direction: column; + align-items: flex-start; + gap: 0.125rem; min-height: 0; - padding: 0 1rem; + padding: 0 0.75rem; border-bottom: 0; cursor: text; font-size: 0.75rem; - line-height: 1.5; + line-height: 1.45; + text-align: left; } .runtime-log-viewport .runtime-log-text-view [data-log-line]::before, @@ -4612,12 +4655,123 @@ html[data-theme="custom"] .runtime-log-columns { } .runtime-log-viewport .runtime-log-text-view [data-line-text] { + align-items: flex-start; flex: 1 1 auto; overflow: visible; overflow-wrap: anywhere; + white-space: normal; +} + +.runtime-log-viewport .runtime-log-text-view .runtime-log-message { + overflow: visible; + overflow-wrap: anywhere; + text-overflow: unset; white-space: pre-wrap; } +@media (min-width: 640px) { + .runtime-log-viewport .runtime-log-text-view [data-log-line]:not(.hidden) { + flex-direction: row; + gap: 0.5rem; + } +} + +.runtime-logs-workspace { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.runtime-logs-fill, +.runtime-logs-fill > #screen, +.runtime-logs-fill .runtime-logs-stream, +.runtime-logs-fill .runtime-log-panel { + display: flex; + min-width: 0; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; +} + +/* Comes after the shared min-height: 0 so a phone still gets a tall stream. */ +.runtime-logs-fill { + min-height: 70dvh; +} + +/* The page fills the area inside
. Those paddings live in layouts/app.blade.php: + lg:pt-[calc(3rem+1.75rem)] and lg:pb-10. Width stays on that layout's pageWidth wrapper. */ +.runtime-logs-page { + display: flex; + flex-direction: column; +} + +.runtime-logs-fill .runtime-log-loading, +.runtime-logs-fill .runtime-log-empty { + position: absolute; + inset: 0; + min-height: 0; +} + +@media (min-width: 1280px) { + .runtime-logs-page { + height: calc(100dvh - 4.75rem - 2.5rem); + } + + .runtime-logs-page > .application-settings-workspace { + display: flex; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; + } + + .runtime-logs-page > .application-settings-workspace > .grid { + flex: 1 1 auto; + min-height: 0; + grid-template-rows: minmax(0, 1fr); + } + + .runtime-logs-column { + display: flex; + min-height: 0; + flex-direction: column; + } + + .runtime-logs-column > .w-full { + display: flex; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; + } + + .runtime-logs-workspace { + height: auto; + min-height: 0; + flex: 1 1 auto; + } + + .runtime-logs-fill { + min-height: 0; + } +} + +.deployment-badge { + @apply inline-flex shrink-0 items-center rounded-md px-1.5 py-0.5 text-[10px] font-semibold tracking-wide; +} + +.deployment-badge-production { + @apply bg-success/10 text-success uppercase ring-1 ring-success/20; +} + +.deployment-badge-preview { + @apply bg-coollabs/10 text-coollabs ring-1 ring-coollabs/20 dark:bg-warning/10 dark:text-warning dark:ring-warning/20; +} + +.runtime-logs-fill .runtime-log-viewport { + min-height: 0; + flex: 1 1 auto; + max-height: none; +} + .runtime-log-viewport .runtime-log-text-view .log-error [data-line-text] { color: #e55e73; } .runtime-log-viewport .runtime-log-text-view .log-warning [data-line-text] { color: #d79945; } .runtime-log-viewport .runtime-log-text-view .log-debug [data-line-text] { color: #929099; } diff --git a/resources/js/runtime-logs.js b/resources/js/runtime-logs.js index 3b34fce197..e34f1a0f6b 100644 --- a/resources/js/runtime-logs.js +++ b/resources/js/runtime-logs.js @@ -12,10 +12,38 @@ import { export const MAX_CLIENT_LOG_LINES = 50000; export const STREAM_INTERVAL_MS = 2000; -const ESTIMATED_ROW_HEIGHT = 44; +const ESTIMATED_ROW_HEIGHT = 30; const ESTIMATED_TEXT_ROW_HEIGHT = 20; const TIMESTAMP_PATTERN = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z) (.*)$/s; +const SOURCE_PREFIX = /^([^\t]+)\t(.*)$/s; +const SOURCE_COLORS = ['#6f6c78', '#6d7ea8', '#6e8b74', '#a98462', '#8d7398', '#6f8f95']; + +function sourceColor(label) { + let hash = 0; + for (let i = 0; i < label.length; i++) { + hash = (hash * 31 + label.charCodeAt(i)) >>> 0; + } + return SOURCE_COLORS[hash % SOURCE_COLORS.length]; +} + +export function formatLogDetails(content) { + const trimmed = String(content ?? '').trim(); + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return content; + try { + return JSON.stringify(JSON.parse(trimmed), null, 2); + } catch { + return content; + } +} + +/** + * A row can expand only when the detail shows more than the row: the row cut + * the text off, or the text is JSON that the detail formats. + */ +export function isExpandableLog(content, truncated) { + return truncated || formatLogDetails(content) !== content; +} export function getLogLevel(content) { if (/\b(error|err|failed|failure|exception|fatal|panic|critical)\b/.test(content)) return 'error'; @@ -32,11 +60,22 @@ 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 sourced = rawLine.match(SOURCE_PREFIX); + const source = sourced ? sourced[1] : ''; + const body = sourced ? sourced[2] : rawLine; + const match = body.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) })); + const text = match ? match[2] : body; + const lower = `${source}\n${text}`.toLowerCase(); + lines.push(Object.freeze({ + id: nextId(), + ts, + text, + source, + sourceColor: source ? sourceColor(source) : '', + lower, + level: getLogLevel(text.toLowerCase()), + })); } return lines; } @@ -45,7 +84,7 @@ export function parseLogLines(output, nextId) { * 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) { +function linesSinceBoundary(existing, incoming) { let lastTs = ''; for (let i = existing.length - 1; i >= 0; i--) { if (existing[i].ts) { @@ -72,6 +111,34 @@ export function newLinesSince(existing, incoming) { }); } +function groupLinesBySource(entries) { + const groups = new Map(); + for (const line of entries) { + const key = line.source || ''; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(line); + } + return groups; +} + +export function newLinesSince(existing, incoming) { + const sourced = existing.some((line) => line.source) || incoming.some((line) => line.source); + if (!sourced) return linesSinceBoundary(existing, incoming); + + const existingBySource = groupLinesBySource(existing); + const fresh = []; + for (const [source, group] of groupLinesBySource(incoming)) { + fresh.push(...linesSinceBoundary(existingBySource.get(source) ?? [], group)); + } + fresh.sort((left, right) => { + if (left.ts === right.ts) return (left.source || '').localeCompare(right.source || ''); + if (!left.ts) return 1; + if (!right.ts) return -1; + return left.ts < right.ts ? -1 : 1; + }); + return fresh; +} + export function lastTimestamp(lines) { for (let i = lines.length - 1; i >= 0; i--) { if (lines[i].ts) return lines[i].ts; @@ -158,6 +225,10 @@ function downloadText(content, filename) { URL.revokeObjectURL(url); } +export function prefersTextView(stored) { + return stored !== 'false'; +} + export function initializeRuntimeLogsComponent() { window.Alpine.data('runtimeLogs', (config = {}) => { let lines = []; @@ -165,7 +236,7 @@ export function initializeRuntimeLogsComponent() { let lineId = 0; let virtualizer = null; let cleanupVirtualizer = null; - let headerObserver = null; + let layoutObserver = null; let streamTimer = null; const nextId = () => ++lineId; const formatTimestamp = createTimestampFormatter(config.timezone); @@ -188,7 +259,7 @@ export function initializeRuntimeLogsComponent() { scrollDebounce: null, destroyed: false, colorLogs: localStorage.getItem('coolify-color-logs') === 'true', - textView: localStorage.getItem('coolify-log-text-view') === 'true', + textView: prefersTextView(localStorage.getItem('coolify-log-text-view')), logFilters: readLogFilters(localStorage.getItem('coolify-log-filters')), searchQuery: '', appliedQuery: '', @@ -198,10 +269,12 @@ export function initializeRuntimeLogsComponent() { virtualRows: [], totalSize: 0, scrollMargin: 0, + multipleSources: false, init() { this.allLines = Number(this.$wire.numberOfLines) === -1; this.$watch('searchQuery', () => this.applyFilters()); + this.$watch('loading', () => this.$nextTick(() => this.updateVirtualizer())); this.$nextTick(() => this.mountVirtualizer()); streamTimer = setInterval(() => this.streamTick(), STREAM_INTERVAL_MS); if (this.expanded) { @@ -214,7 +287,7 @@ export function initializeRuntimeLogsComponent() { this.destroyed = true; clearInterval(streamTimer); clearTimeout(this.scrollDebounce); - headerObserver?.disconnect(); + layoutObserver?.disconnect(); cleanupVirtualizer?.(); virtualizer = null; }, @@ -227,30 +300,66 @@ export function initializeRuntimeLogsComponent() { estimateSize: () => (this.textView ? ESTIMATED_TEXT_ROW_HEIGHT : 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', + // Follow mode keeps the newest line in view. Otherwise the first line stays at the top. + anchorTo: 'start', observeElementRect, observeElementOffset, scrollToFn: elementScroll, - measureElement, - onChange: () => this.syncRows(), + // A row can be measured before its text is laid out. Caching that 0 + // collapses the top of the list and the first lines never come back. + measureElement: (element, entry, instance) => { + const size = measureElement(element, entry, instance); + if (size > 0) return size; + const index = instance.indexFromElement(element); + const key = instance.options.getItemKey(index); + return instance.itemSizeCache.get(key) ?? instance.options.estimateSize(index); + }, + onChange: () => { + // Measurement corrections can advance the internal offset while the + // element is still at the top, which hides the first lines. + this.pinScrollOffset(); + this.syncRows(); + }, }); cleanupVirtualizer = virtualizer._didMount(); virtualizer._willUpdate(); - if (this.$refs.columns && window.ResizeObserver) { - headerObserver = new ResizeObserver(() => this.updateVirtualizer()); - headerObserver.observe(this.$refs.columns); + if (window.ResizeObserver) { + layoutObserver = new ResizeObserver(() => { + const margin = this.measureScrollMargin(); + if (margin !== this.scrollMargin) this.updateVirtualizer(); + }); + if (this.$refs.list) layoutObserver.observe(this.$refs.list); + if (this.$refs.viewport) layoutObserver.observe(this.$refs.viewport); } this.updateVirtualizer(); }, + measureScrollMargin() { + const list = this.$refs.list; + const viewport = this.$refs.viewport; + if (!list || !viewport || this.lineCount === 0) return 0; + const margin = list.getBoundingClientRect().top - viewport.getBoundingClientRect().top + viewport.scrollTop; + return Number.isFinite(margin) ? Math.max(0, margin) : 0; + }, + + // Keep the virtual offset on the real scroll position while the reader is at the top. + pinScrollOffset() { + const viewport = this.$refs.viewport; + if (!virtualizer || this.alwaysScroll || !viewport || viewport.scrollTop !== 0) return; + if (!virtualizer.scrollOffset && !virtualizer.scrollAdjustments) return; + virtualizer.scrollOffset = 0; + virtualizer.scrollAdjustments = 0; + }, + updateVirtualizer() { if (!virtualizer) return; - this.scrollMargin = this.$refs.list?.offsetTop ?? 0; + this.pinScrollOffset(); + this.scrollMargin = this.measureScrollMargin(); virtualizer.setOptions({ ...virtualizer.options, count: filtered.length, scrollMargin: this.scrollMargin, + anchorTo: this.alwaysScroll ? 'end' : 'start', // A new function makes the virtualizer recalculate keys after every data change. getItemKey: (index) => filtered[index]?.id ?? index, }); @@ -266,7 +375,13 @@ export function initializeRuntimeLogsComponent() { this.totalSize = virtualizer.getTotalSize(); }, - measureRow(el) { + measureRow(el, logLine = null) { + const line = el.querySelector('[data-log-line]'); + const message = line?.querySelector('.runtime-log-message'); + if (line && message && !this.textView) { + const text = logLine?.text ?? message.textContent; + line.toggleAttribute('data-expandable', isExpandableLog(text, message.scrollWidth > message.clientWidth)); + } virtualizer?.measureElement(el); }, @@ -303,9 +418,25 @@ export function initializeRuntimeLogsComponent() { } }, + sinceArgument() { + const bySource = {}; + let last = null; + let sourced = false; + for (const line of lines) { + if (!line.ts) continue; + last = line.ts; + if (line.source) { + sourced = true; + bySource[line.source] = line.ts; + } + } + if (!sourced) return last; + return Object.keys(bySource).length > 0 ? bySource : null; + }, + async streamTick() { if (this.destroyed || this.loading || !this.expanded || document.hidden || !this.$wire.streamLogs) return; - const since = lastTimestamp(lines); + const since = this.sinceArgument(); if (!since) { await this.refresh(); return; @@ -341,6 +472,8 @@ export function initializeRuntimeLogsComponent() { this.appliedQuery = this.searchQuery.trim(); this.matchCount = this.appliedQuery ? filtered.length : 0; this.lineCount = lines.length; + const sources = new Set(filtered.map((line) => line.source).filter(Boolean)); + this.multipleSources = sources.size > 1; // Wait for x-show to reveal the list so its offset can be measured. this.$nextTick(() => { this.updateVirtualizer(); @@ -350,8 +483,12 @@ export function initializeRuntimeLogsComponent() { visibleText(source = filtered) { const withTime = this.$wire.showTimeStamps; + const showSource = new Set(source.map((line) => line.source).filter(Boolean)).size > 1; return source - .map((line) => (withTime && line.ts ? `${formatTimestamp(line.ts)} ${line.text}` : line.text)) + .map((line) => { + const body = withTime && line.ts ? `${formatTimestamp(line.ts)} ${line.text}` : line.text; + return showSource && line.source ? `${line.source} | ${body}` : body; + }) .join('\n'); }, @@ -364,11 +501,7 @@ export function initializeRuntimeLogsComponent() { }, formatLogDetails(content) { - try { - return JSON.stringify(JSON.parse(content), null, 2); - } catch { - return content; - } + return formatLogDetails(content); }, toggleLogDetails(id, event) { @@ -376,6 +509,7 @@ export function initializeRuntimeLogsComponent() { if (window.getSelection()?.toString()) return; if (event.type === 'keydown' && !['Enter', ' '].includes(event.key)) return; if (event.type === 'keydown') event.preventDefault(); + if (!this.expandedLogs[id] && !event.currentTarget?.hasAttribute('data-expandable')) return; this.expandedLogs[id] = !this.expandedLogs[id]; }, @@ -467,6 +601,7 @@ export function initializeRuntimeLogsComponent() { toggleScroll() { this.alwaysScroll = !this.alwaysScroll; this.followManuallyDisabled = !this.alwaysScroll; + this.updateVirtualizer(); if (this.alwaysScroll) this.scrollToBottom(); }, @@ -500,8 +635,10 @@ export function initializeRuntimeLogsComponent() { if (this.destroyed) return; const el = event.target; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - if (!this.alwaysScroll && !this.followManuallyDisabled && distanceFromBottom <= 10) { + const overflows = el.scrollHeight - el.clientHeight > 10; + if (!this.alwaysScroll && !this.followManuallyDisabled && overflows && el.scrollTop > 0 && distanceFromBottom <= 10) { this.alwaysScroll = true; + this.updateVirtualizer(); } }, 150); }, diff --git a/resources/js/runtime-logs.test.js b/resources/js/runtime-logs.test.js index c802f9b2bb..c79b6cf3aa 100644 --- a/resources/js/runtime-logs.test.js +++ b/resources/js/runtime-logs.test.js @@ -9,6 +9,8 @@ import { lastTimestamp, newLinesSince, parseLogLines, + prefersTextView, + isExpandableLog, } from './runtime-logs.js'; const idGenerator = () => { @@ -16,6 +18,12 @@ const idGenerator = () => { return () => ++id; }; +test('text view is the default unless it was turned off', () => { + assert.equal(prefersTextView(null), true); + assert.equal(prefersTextView('true'), true); + assert.equal(prefersTextView('false'), false); +}); + 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', @@ -88,6 +96,40 @@ test('formats timestamps in the server timezone and falls back to UTC', () => { assert.equal(createTimestampFormatter('Not/AZone')('2025-12-04T11:48:39Z'), '2025-Dec-04 11:48:39'); }); +test('reads a source tag and searches it without changing the level', () => { + const lines = parseLogLines( + 'Frankfurt\t2026-09-28T10:00:01Z hello from frankfurt\nHelsinki\t2026-09-28T10:00:02Z ERROR failed there\nplain line', + idGenerator(), + ); + + assert.deepEqual(lines.map(({ source, ts, text, level }) => ({ source, ts, text, level })), [ + { source: 'Frankfurt', ts: '2026-09-28T10:00:01Z', text: 'hello from frankfurt', level: 'info' }, + { source: 'Helsinki', ts: '2026-09-28T10:00:02Z', text: 'ERROR failed there', level: 'error' }, + { source: '', ts: '', text: 'plain line', level: 'info' }, + ]); + assert.deepEqual( + filterLogLines(lines, { error: true, warning: true, debug: true, info: true }, 'helsinki').map((line) => line.source), + ['Helsinki'], + ); +}); + +test('drops repeated lines per source and keeps timestamp order', () => { + const nextId = idGenerator(); + const existing = parseLogLines( + 'Frankfurt\t2026-09-28T10:00:01Z same\nHelsinki\t2026-09-28T10:00:01Z same', + nextId, + ); + const incoming = parseLogLines( + 'Frankfurt\t2026-09-28T10:00:01Z same\nHelsinki\t2026-09-28T10:00:02Z next\nFrankfurt\t2026-09-28T10:00:03Z later', + nextId, + ); + + assert.deepEqual(newLinesSince(existing, incoming).map((line) => [line.source, line.text]), [ + ['Helsinki', 'next'], + ['Frankfurt', 'later'], + ]); +}); + test('detects Ctrl+F and Cmd+F only', () => { assert.equal(isFindShortcut({ ctrlKey: true, key: 'f' }), true); assert.equal(isFindShortcut({ metaKey: true, key: 'F' }), true); @@ -96,3 +138,10 @@ test('detects Ctrl+F and Cmd+F only', () => { assert.equal(isFindShortcut({ ctrlKey: true, altKey: true, key: 'f' }), false); assert.equal(isFindShortcut({ ctrlKey: true, key: 'g' }), false); }); + +test('a row expands only when the detail shows more than the row', () => { + assert.equal(isExpandableLog('JOB QUEUE', false), false); + assert.equal(isExpandableLog('{ broken json', false), false); + assert.equal(isExpandableLog('a very long line', true), true); + assert.equal(isExpandableLog('{"level":"info","id":1}', false), true); +}); diff --git a/resources/views/components/forms/listbox.blade.php b/resources/views/components/forms/listbox.blade.php index 58cb9b406b..4677aac362 100644 --- a/resources/views/components/forms/listbox.blade.php +++ b/resources/views/components/forms/listbox.blade.php @@ -4,7 +4,7 @@ 'label' => null, 'helper' => null, 'required' => false, - 'options' => [], // list of ['value' => ..., 'label' => ..., 'description' => ?string, 'disabled' => bool] + 'options' => [], // list of ['value' => ..., 'label' => ..., 'description' => ?string, 'disabled' => bool, 'dot' => ?string, 'state' => ?string, 'labelClass' => ?string] 'placeholder' => 'Select…', 'emptyText' => 'No options available.', 'live' => false, @@ -90,7 +90,7 @@ get visibleOptions() { const query = this.query.toLowerCase().trim(); if (query === '') return this.options; - return this.options.filter((option) => !option.header && [option.label, option.value, option.description] + return this.options.filter((option) => !option.header && [option.label, option.value, option.description, option.state] .some((text) => String(text ?? '').toLowerCase().includes(query))); }, get customOption() { @@ -108,6 +108,21 @@ return this.options; }, @endif + get selectedOption() { + @if ($multiple) + return null; + @else + return this.options.find((option) => String(option.value) === String(this.value)) ?? null; + @endif + }, + dotClass(dot) { + return { + success: 'bg-success', + warning: 'bg-warning', + error: 'bg-error', + neutral: 'bg-neutral-400', + }[dot] ?? ''; + }, value: @if (!$wire) @js($value) @elseif ($live && ! $onChange) @entangle($id).live @else @entangle($id) @endif, @if ($multiple) get selected() { @@ -216,7 +231,11 @@ @@ -331,14 +357,21 @@ diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php index 2b80f8be12..da568d9d1d 100644 --- a/resources/views/livewire/project/shared/get-logs.blade.php +++ b/resources/views/livewire/project/shared/get-logs.blade.php @@ -1,11 +1,11 @@ -
+
$fill])> @if (! $canReadLogs) Container logs can contain secrets. @else -
$collapsible])> -
$fill]) x-data="runtimeLogs(@js([ 'collapsible' => $collapsible, 'expanded' => $expandByDefault || ! $collapsible, 'containerName' => $container ?? 'logs', @@ -35,10 +35,11 @@
@endif
$fill, 'mx-auto w-full' => ! $fill]) + :class="fullscreen ? 'fullscreen flex flex-col !overflow-visible' : 'relative'" :style="fullscreen ? 'max-height: none !important; height: 100% !important;' : ''">
+ :class="fullscreen ? 'h-full w-full' : '{{ $fill ? 'flex min-h-0 flex-1 flex-col' : '' }}'">
@@ -199,6 +200,7 @@ x-on:click=" open = false; $wire.copyLogs().then(logs => { + logs = logs.replace(/^([^\t\n]+)\t/gm, '$1 | '); if (!navigator.clipboard?.writeText) { Livewire.dispatch('error', ['Clipboard is not available. Please use HTTPS or localhost.']); return; @@ -257,8 +259,8 @@
-
+ :class="fullscreen ? 'flex-1' : '{{ $fill ? 'min-h-0 flex-1' : 'max-h-[min(40rem,70dvh)] sm:max-h-[40rem]' }}'"> +
Loading logs
@@ -270,7 +272,7 @@
{{-- Alpine renders only the visible rows. Livewire must not morph them. --}}