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. --}}