mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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.
This commit is contained in:
@@ -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<array{server: string, container: string, label: string}>
|
||||
*/
|
||||
#[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<string, mixed>|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<string, mixed>|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<array{label: string, logs: string}> $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<array{server: Server, container: string, label: string}>|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);
|
||||
|
||||
@@ -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<array{value: string, label: string, labelClass: string}>,
|
||||
* services: list<array{value: string, label: string, dot: string, description?: string}>,
|
||||
* deployment: string,
|
||||
* service: string,
|
||||
* sources: list<array{server: string, container: string, label: string}>,
|
||||
* streamKey: string,
|
||||
* }
|
||||
*/
|
||||
private function selectionTargets(): array
|
||||
{
|
||||
return RuntimeLogTargets::select(
|
||||
$this->logServerPayload(),
|
||||
$this->pullRequestId,
|
||||
$this->service,
|
||||
$this->type === 'application',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{uuid: string, name: string, containers: list<array<string, mixed>>}>
|
||||
*/
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Groups running and stopped containers for the runtime logs page.
|
||||
*
|
||||
* A service is one stream. Every container of that service, on every server and
|
||||
* from a rollout that left the previous container behind, is a source of that stream.
|
||||
* Deployments are production, then preview pull requests from newest to oldest.
|
||||
* Services are alphabetical.
|
||||
*/
|
||||
class RuntimeLogTargets
|
||||
{
|
||||
/**
|
||||
* @param list<array{uuid: string, name: string, containers: list<array<string, mixed>>}> $servers
|
||||
* @return array{
|
||||
* deployments: list<array{value: string, label: string, labelClass: string}>,
|
||||
* services: list<array{value: string, label: string, dot: string, description?: string, state?: string}>,
|
||||
* deployment: string,
|
||||
* service: string,
|
||||
* sources: list<array{server: string, container: string, label: string}>,
|
||||
* 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<empty>,
|
||||
* services: list<empty>,
|
||||
* deployment: string,
|
||||
* service: string,
|
||||
* sources: list<empty>,
|
||||
* streamKey: string,
|
||||
* }
|
||||
*/
|
||||
private static function emptySelection(): array
|
||||
{
|
||||
return [
|
||||
'deployments' => [],
|
||||
'services' => [],
|
||||
'deployment' => '0',
|
||||
'service' => '',
|
||||
'sources' => [],
|
||||
'streamKey' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{uuid: string, name: string, containers: list<array<string, mixed>>}> $servers
|
||||
* @return list<array{name: string, key: string, state: string, deployment: string, server: string, serverName: string, serverIndex: int}>
|
||||
*/
|
||||
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<string, mixed> $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<array{deployment: string}> $containers
|
||||
* @return list<string>
|
||||
*/
|
||||
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<string> $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<string> $ids
|
||||
* @return list<array{value: string, label: string, labelClass: string}>
|
||||
*/
|
||||
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<array{key: string}> $containers
|
||||
* @return array<string, list<array{name: string, key: string, state: string, deployment: string, server: string, serverName: string, serverIndex: int}>>
|
||||
*/
|
||||
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<string, list<array{state: string}>> $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<string, list<array{state: string}>> $groups
|
||||
* @return list<array{value: string, label: string, dot: string, description?: string, state?: string}>
|
||||
*/
|
||||
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<string> $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<string> $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<array{name: string, server: string, serverName: string, serverIndex: int}> $containers
|
||||
* @return list<array{server: string, container: string, label: string}>
|
||||
*/
|
||||
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<array{server: string, container: string, label: string}> $sources
|
||||
* @return list<array{server: string, container: string, label: string}>
|
||||
*/
|
||||
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<array{server: string, container: string}> $sources
|
||||
*/
|
||||
private static function streamKey(array $sources): string
|
||||
{
|
||||
return implode('|', array_map(
|
||||
fn (array $source): string => $source['server'].':'.$source['container'],
|
||||
$sources,
|
||||
));
|
||||
}
|
||||
}
|
||||
+167
-13
@@ -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 <main>. 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; }
|
||||
|
||||
+163
-26
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 @@
|
||||
<button x-ref="trigger" id="{{ $triggerId }}" type="button" class="listbox-trigger" @click="toggle()"
|
||||
@disabled($disabled) {{ $attributes->whereStartsWith('x-bind:disabled') }} aria-haspopup="listbox"
|
||||
:aria-expanded="open" @if ($tooltip) :title="title" @endif>
|
||||
<span class="listbox-trigger-label" x-text="current">{{ $multiple ? $multipleLabel : '' }}</span>
|
||||
<span class="listbox-trigger-label flex items-center gap-2">
|
||||
<span x-show="selectedOption?.dot" class="size-1.5 shrink-0 rounded-full" :class="dotClass(selectedOption?.dot)"></span>
|
||||
<span class="min-w-0 truncate" :class="selectedOption?.labelClass" x-text="current">{{ $multiple ? $multipleLabel : '' }}</span>
|
||||
<span x-show="selectedOption?.state" class="shrink-0 text-xs text-neutral-500 dark:text-fg-dim" x-text="selectedOption?.state"></span>
|
||||
</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" class="size-3.5 shrink-0 opacity-60">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8 9 4-4 4 4m0 6-4 4-4-4" />
|
||||
@@ -266,14 +285,21 @@
|
||||
<button type="button" class="listbox-option" role="option"
|
||||
:class="{ 'listbox-option-disabled': option.disabled }"
|
||||
:aria-selected="isSelected(option)" @click="choose(option)">
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="truncate" x-text="option.label"></span>
|
||||
<span x-show="option.description" x-text="option.description"
|
||||
class="text-xs whitespace-normal text-neutral-500 dark:text-fg-dim"></span>
|
||||
<span class="flex min-w-0 items-center gap-2 text-left">
|
||||
<span x-show="option.dot" class="size-1.5 shrink-0 rounded-full" :class="dotClass(option.dot)"></span>
|
||||
<span class="flex min-w-0 flex-col items-start">
|
||||
<span class="flex min-w-0 items-baseline gap-2">
|
||||
<span class="truncate" :class="option.labelClass" x-text="option.label"></span>
|
||||
<span x-show="option.state" x-text="option.state"
|
||||
class="shrink-0 text-xs text-neutral-500 dark:text-fg-dim"></span>
|
||||
</span>
|
||||
<span x-show="option.description" x-text="option.description"
|
||||
class="text-xs whitespace-normal text-neutral-500 dark:text-fg-dim"></span>
|
||||
</span>
|
||||
</span>
|
||||
<svg x-show="isSelected(option)" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"
|
||||
class="size-3.5 shrink-0">
|
||||
class="ml-auto size-3.5 shrink-0">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -331,14 +357,21 @@
|
||||
<button type="button" class="listbox-option" role="option"
|
||||
:class="{ 'listbox-option-disabled': option.disabled }"
|
||||
:aria-selected="isSelected(option)" @click="choose(option)">
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="truncate" x-text="option.label"></span>
|
||||
<span x-show="option.description" x-text="option.description"
|
||||
class="text-xs whitespace-normal text-neutral-500 dark:text-fg-dim"></span>
|
||||
<span class="flex min-w-0 items-center gap-2 text-left">
|
||||
<span x-show="option.dot" class="size-1.5 shrink-0 rounded-full" :class="dotClass(option.dot)"></span>
|
||||
<span class="flex min-w-0 flex-col items-start">
|
||||
<span class="flex min-w-0 items-baseline gap-2">
|
||||
<span class="truncate" :class="option.labelClass" x-text="option.label"></span>
|
||||
<span x-show="option.state" x-text="option.state"
|
||||
class="shrink-0 text-xs text-neutral-500 dark:text-fg-dim"></span>
|
||||
</span>
|
||||
<span x-show="option.description" x-text="option.description"
|
||||
class="text-xs whitespace-normal text-neutral-500 dark:text-fg-dim"></span>
|
||||
</span>
|
||||
</span>
|
||||
<svg x-show="isSelected(option)" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"
|
||||
class="size-3.5 shrink-0">
|
||||
class="ml-auto size-3.5 shrink-0">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<div>
|
||||
<div @class(['flex min-h-0 min-w-0 flex-1 flex-col' => $fill])>
|
||||
@if (! $canReadLogs)
|
||||
<x-callout type="info" title="Hidden (only admins can view)">
|
||||
Container logs can contain secrets.
|
||||
</x-callout>
|
||||
@else
|
||||
<div @class(['w-full min-w-0', 'runtime-log-shell' => $collapsible])>
|
||||
<div id="screen" x-data="runtimeLogs(@js([
|
||||
<div @class(['w-full min-w-0', 'runtime-log-shell' => $collapsible, 'runtime-logs-fill flex flex-1 flex-col' => $fill])>
|
||||
<div id="screen" @class(['flex min-h-0 flex-1 flex-col' => $fill]) x-data="runtimeLogs(@js([
|
||||
'collapsible' => $collapsible,
|
||||
'expanded' => $expandByDefault || ! $collapsible,
|
||||
'containerName' => $container ?? 'logs',
|
||||
@@ -35,10 +35,11 @@
|
||||
</div>
|
||||
@endif
|
||||
<div x-show="expanded" {{ $collapsible ? 'x-collapse.duration.200ms' : '' }}
|
||||
:class="fullscreen ? 'fullscreen flex flex-col !overflow-visible' : 'relative w-full mx-auto'"
|
||||
@class(['runtime-logs-stream', 'flex min-h-0 w-full flex-1 flex-col' => $fill, 'mx-auto w-full' => ! $fill])
|
||||
:class="fullscreen ? 'fullscreen flex flex-col !overflow-visible' : 'relative'"
|
||||
:style="fullscreen ? 'max-height: none !important; height: 100% !important;' : ''">
|
||||
<div class="runtime-log-panel"
|
||||
:class="fullscreen ? 'h-full w-full' : ''">
|
||||
:class="fullscreen ? 'h-full w-full' : '{{ $fill ? 'flex min-h-0 flex-1 flex-col' : '' }}'">
|
||||
<div class="runtime-log-toolbar logs-viewer-toolbar">
|
||||
<div class="logs-viewer-toolbar-controls">
|
||||
<div class="logs-viewer-actions">
|
||||
@@ -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 @@
|
||||
<div id="logsContainer" x-ref="viewport" @scroll="handleScroll" @wheel="handleWheel"
|
||||
@touchstart="handleTouchStart" @touchmove="handleTouchMove" @keydown="handleKeyScroll" tabindex="0"
|
||||
class="runtime-log-viewport logs-viewer-viewport relative flex min-w-0 w-full flex-col overflow-x-hidden overflow-y-auto scrollbar"
|
||||
:class="fullscreen ? 'flex-1' : 'max-h-[min(40rem,70dvh)] sm:max-h-[40rem]'">
|
||||
<div class="runtime-log-loading" x-show="loading && lineCount === 0" role="status">
|
||||
:class="fullscreen ? 'flex-1' : '{{ $fill ? 'min-h-0 flex-1' : 'max-h-[min(40rem,70dvh)] sm:max-h-[40rem]' }}'">
|
||||
<div class="runtime-log-loading" x-show="loading && lineCount === 0" x-cloak role="status">
|
||||
<x-loading compact aria-label="Loading logs" />
|
||||
<span>Loading logs</span>
|
||||
</div>
|
||||
@@ -270,7 +272,7 @@
|
||||
</div>
|
||||
{{-- Alpine renders only the visible rows. Livewire must not morph them. --}}
|
||||
<div id="logs" wire:ignore x-cloak x-show="lineCount > 0"
|
||||
:class="{ 'runtime-log-without-time': !$wire.showTimeStamps, 'runtime-log-text-view': textView }"
|
||||
:class="{ 'runtime-log-without-time': !$wire.showTimeStamps, 'runtime-log-text-view': textView, 'mt-auto': alwaysScroll }"
|
||||
class="font-logs max-w-full cursor-default text-[11px] leading-relaxed sm:text-xs">
|
||||
<div x-ref="columns" x-show="!textView" class="runtime-log-columns" aria-hidden="true">
|
||||
<span x-show="$wire.showTimeStamps">Time</span>
|
||||
@@ -281,7 +283,7 @@
|
||||
<template x-for="row in virtualRows" :key="row.key">
|
||||
<div class="runtime-log-virtual-row" :data-index="row.index"
|
||||
:style="{ transform: 'translateY(' + (row.start - scrollMargin) + 'px)' }"
|
||||
x-init="$nextTick(() => measureRow($el))">
|
||||
x-init="$nextTick(() => measureRow($el, row.line))">
|
||||
<div data-log-line :role="textView ? null : 'button'" :tabindex="textView ? null : 0" :data-log-level="row.line.level"
|
||||
:class="colorLogs ? 'log-' + row.line.level : ''"
|
||||
:aria-expanded="isLogExpanded(row.line.id)"
|
||||
@@ -291,7 +293,7 @@
|
||||
<template x-if="$wire.showTimeStamps && row.line.ts">
|
||||
<span class="logs-viewer-timestamp text-gray-500" x-text="formatTimestamp(row.line.ts)"></span>
|
||||
</template>
|
||||
<span data-line-text class="logs-viewer-line-text"><template x-for="(segment, segmentIndex) in highlight(row.line.text)" :key="segmentIndex"><span :class="segment.match ? 'log-highlight' : ''" x-text="segment.text"></span></template></span>
|
||||
<span data-line-text class="logs-viewer-line-text"><template x-if="multipleSources && row.line.source"><span class="runtime-log-source" :style="{ color: row.line.sourceColor }" x-text="row.line.source"></span></template><span class="runtime-log-message"><template x-for="(segment, segmentIndex) in highlight(row.line.text)" :key="segmentIndex"><span :class="segment.match ? 'log-highlight' : ''" x-text="segment.text"></span></template></span></span>
|
||||
</div>
|
||||
<template x-if="isLogExpanded(row.line.id)">
|
||||
<pre class="runtime-log-detail" aria-label="Full log entry"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div>
|
||||
<div class="runtime-logs-page">
|
||||
<x-slot:title>
|
||||
{{ data_get_str($resource, 'name')->limit(10) }} > Logs | Coolify
|
||||
</x-slot>
|
||||
@@ -21,6 +21,12 @@
|
||||
|| $functionalServers->isEmpty()
|
||||
|| $totalContainers === 0
|
||||
);
|
||||
$logTargets = $targets ?? null;
|
||||
$logSources = is_array($logTargets) ? ($logTargets['sources'] ?? []) : [];
|
||||
$primarySource = $logSources[0] ?? null;
|
||||
$selectedLogServer = is_array($primarySource)
|
||||
? $servers->firstWhere('uuid', $primarySource['server'])
|
||||
: null;
|
||||
@endphp
|
||||
|
||||
@if (in_array($type, ['application', 'database', 'service'], true))
|
||||
@@ -33,7 +39,7 @@
|
||||
@else
|
||||
<x-service.configuration-sidebar :service="$resource" current-route="project.service.logs" />
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
<div class="runtime-logs-column min-w-0">
|
||||
@endif
|
||||
|
||||
<div wire:loading.block wire:target="loadAllContainers" class="hidden w-full">
|
||||
@@ -63,32 +69,50 @@
|
||||
icon-name="file-content" />
|
||||
@endif
|
||||
@else
|
||||
<div class="flex flex-col gap-4">
|
||||
@foreach ($servers as $server)
|
||||
@if (! $server->isFunctional())
|
||||
<div class="runtime-logs-workspace">
|
||||
@foreach ($servers as $serverModel)
|
||||
@if (! $serverModel->isFunctional())
|
||||
<x-callout type="warning" title="Server unavailable">
|
||||
{{ $server->name }} is not functional, so its container logs cannot be loaded.
|
||||
{{ $serverModel->name }} is not functional, so its container logs cannot be loaded.
|
||||
</x-callout>
|
||||
@elseif (isset($serverErrors[$server->id]))
|
||||
@elseif (isset($serverErrors[$serverModel->id]))
|
||||
<x-callout type="warning" title="Could not load containers">
|
||||
{{ $server->name }}: {{ $serverErrors[$server->id] }}
|
||||
{{ $serverModel->name }}: {{ $serverErrors[$serverModel->id] }}
|
||||
</x-callout>
|
||||
@elseif (isset($serverContainers[$server->id]) && count($serverContainers[$server->id]) > 0)
|
||||
@if ($servers->count() > 1)
|
||||
<div class="flex items-center gap-2 px-1">
|
||||
<x-reicon name="servers" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
|
||||
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">{{ $server->name }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@foreach ($serverContainers[$server->id] as $container)
|
||||
<livewire:project.shared.get-logs
|
||||
wire:key="{{ data_get($container, 'ID', uniqid()) }}" :server="$server"
|
||||
:resource="$resource" :container="data_get($container, 'Names')"
|
||||
:expandByDefault="$totalContainers === 1" />
|
||||
@endforeach
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@if ($selectedLogServer && $logSources !== [])
|
||||
@if (count($logTargets['deployments']) > 1 || count($logTargets['services']) > 1)
|
||||
<div class="flex shrink-0 flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
|
||||
@if (count($logTargets['deployments']) > 1)
|
||||
<div class="w-full sm:w-60" wire:key="runtime-logs-deployments-{{ collect($logTargets['deployments'])->map(fn (array $option): string => $option['value'].$option['labelClass'])->join('-') }}">
|
||||
<x-forms.listbox id="pullRequestId" label="Deployment" live searchable portal
|
||||
searchPlaceholder="Search deployments" :options="$logTargets['deployments']" />
|
||||
</div>
|
||||
@endif
|
||||
@if (count($logTargets['services']) > 1)
|
||||
<div class="w-full sm:w-60" wire:key="runtime-logs-services-{{ collect($logTargets['services'])->map(fn (array $option): string => $option['value'].$option['label'].$option['dot'].($option['state'] ?? '').($option['description'] ?? ''))->join('|') }}">
|
||||
<x-forms.listbox id="service" label="Service" live searchable portal
|
||||
searchPlaceholder="Search services" :options="$logTargets['services']" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<livewire:project.shared.get-logs
|
||||
wire:key="runtime-logs-{{ $logTargets['streamKey'] }}"
|
||||
:server="$selectedLogServer"
|
||||
:resource="$resource"
|
||||
:container="$primarySource['container']"
|
||||
:sources="count($logSources) > 1 ? $logSources : []"
|
||||
:collapsible="false"
|
||||
:fill="true" />
|
||||
@else
|
||||
<x-empty size="lg" title="Runtime logs unavailable"
|
||||
description="No containers are running, so there are no runtime logs to show."
|
||||
icon-name="file-content" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\GetLogs;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Process\PendingProcess;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->server = functionalLogServer($this->team->id, $privateKey->id, 'Frankfurt');
|
||||
$this->edge = functionalLogServer($this->team->id, $privateKey->id, 'Helsinki');
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
|
||||
$edgeDestination = StandaloneDocker::where('server_id', $this->edge->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'build_pack' => 'dockerfile',
|
||||
]);
|
||||
$this->application->additional_networks()->attach($edgeDestination->id, ['server_id' => $this->edge->id]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
function functionalLogServer(int $teamId, int $privateKeyId, string $name): Server
|
||||
{
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $teamId,
|
||||
'private_key_id' => $privateKeyId,
|
||||
'name' => $name,
|
||||
]);
|
||||
$server->settings->fill([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => false,
|
||||
])->save();
|
||||
|
||||
return Server::with('settings')->findOrFail($server->id);
|
||||
}
|
||||
|
||||
function mergedLogSources(): array
|
||||
{
|
||||
return [
|
||||
['server' => test()->server->uuid, 'container' => 'web-frankfurt', 'label' => 'Frankfurt'],
|
||||
['server' => test()->edge->uuid, 'container' => 'web-helsinki', 'label' => 'Helsinki'],
|
||||
];
|
||||
}
|
||||
|
||||
it('rejects a foreign server or an invalid container name before reading logs', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$foreign = functionalLogServer($otherTeam->id, $this->server->private_key_id, 'Foreign');
|
||||
$unrelated = functionalLogServer($this->team->id, $this->server->private_key_id, 'Unrelated');
|
||||
|
||||
$rejected = [
|
||||
[
|
||||
['server' => $this->server->uuid, 'container' => 'web-frankfurt', 'label' => 'Frankfurt'],
|
||||
['server' => $foreign->uuid, 'container' => 'web-foreign', 'label' => 'Foreign'],
|
||||
],
|
||||
[
|
||||
['server' => $unrelated->uuid, 'container' => 'web', 'label' => 'Unrelated'],
|
||||
],
|
||||
[
|
||||
['server' => $this->server->uuid, 'container' => 'web-frankfurt', 'label' => 'Frankfurt'],
|
||||
['server' => $this->edge->uuid, 'container' => 'bad name', 'label' => 'Bad'],
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($rejected as $sources) {
|
||||
Process::fake();
|
||||
|
||||
Livewire::test(GetLogs::class, [
|
||||
'server' => $this->server,
|
||||
'resource' => $this->application,
|
||||
'container' => 'web-frankfurt',
|
||||
'sources' => $sources,
|
||||
])
|
||||
->call('getLogs')
|
||||
->assertReturned('Unauthorized.');
|
||||
|
||||
Process::assertNothingRan();
|
||||
}
|
||||
|
||||
$this->edge->updateQuietly(['team_id' => $otherTeam->id]);
|
||||
Process::fake();
|
||||
|
||||
Livewire::test(GetLogs::class, [
|
||||
'server' => $this->server,
|
||||
'resource' => $this->application,
|
||||
'container' => 'web-helsinki',
|
||||
'sources' => [
|
||||
['server' => $this->edge->uuid, 'container' => 'web-helsinki', 'label' => 'Helsinki'],
|
||||
],
|
||||
])
|
||||
->call('getLogs')
|
||||
->assertReturned('Unauthorized.');
|
||||
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
it('merges container logs by timestamp and tags each line with its source', function () {
|
||||
Process::fake(function (PendingProcess $process) {
|
||||
$command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
|
||||
if (str_contains($command, 'web-helsinki')) {
|
||||
return Process::result(output: "2026-09-28T10:00:02Z second\n2026-09-28T10:00:01Z first\n");
|
||||
}
|
||||
if (str_contains($command, 'web-frankfurt')) {
|
||||
return Process::result(output: "2026-09-28T10:00:02Z frankfurt\n");
|
||||
}
|
||||
|
||||
return Process::result();
|
||||
});
|
||||
|
||||
$component = Livewire::test(GetLogs::class, [
|
||||
'server' => $this->server,
|
||||
'resource' => $this->application,
|
||||
'container' => 'web-frankfurt',
|
||||
'sources' => mergedLogSources(),
|
||||
'showTimeStamps' => true,
|
||||
]);
|
||||
|
||||
$component->call('getLogs')->assertReturned(implode("\n", [
|
||||
"Helsinki\t2026-09-28T10:00:01Z first",
|
||||
"Frankfurt\t2026-09-28T10:00:02Z frankfurt",
|
||||
"Helsinki\t2026-09-28T10:00:02Z second",
|
||||
]));
|
||||
|
||||
$component->call('downloadAllLogs')->assertReturned(implode("\n", [
|
||||
'Helsinki | first',
|
||||
'Frankfurt | frankfurt',
|
||||
'Helsinki | second',
|
||||
]));
|
||||
|
||||
$component->set('showTimeStamps', true)
|
||||
->call('downloadAllLogs')
|
||||
->assertReturned(implode("\n", [
|
||||
'Helsinki | 2026-09-28T10:00:01Z first',
|
||||
'Frankfurt | 2026-09-28T10:00:02Z frankfurt',
|
||||
'Helsinki | 2026-09-28T10:00:02Z second',
|
||||
]));
|
||||
});
|
||||
|
||||
it('asks each source only for lines after its own timestamp', function () {
|
||||
$commands = [];
|
||||
Process::fake(function (PendingProcess $process) use (&$commands) {
|
||||
$command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
|
||||
$commands[] = $command;
|
||||
|
||||
return Process::result(output: "2026-09-28T10:00:03Z line\n");
|
||||
});
|
||||
|
||||
Livewire::test(GetLogs::class, [
|
||||
'server' => $this->server,
|
||||
'resource' => $this->application,
|
||||
'container' => 'web-frankfurt',
|
||||
'sources' => mergedLogSources(),
|
||||
])->call('getLogs', [
|
||||
'Frankfurt' => '2026-09-28T10:00:01.123456789Z',
|
||||
'Helsinki' => 'not-a-timestamp',
|
||||
]);
|
||||
|
||||
$frankfurt = collect($commands)->first(fn (string $command): bool => str_contains($command, 'web-frankfurt'));
|
||||
$helsinki = collect($commands)->first(fn (string $command): bool => str_contains($command, 'web-helsinki'));
|
||||
|
||||
expect($frankfurt)->toContain('--since 2026-09-28T10:00:01.123456789Z')
|
||||
->and($helsinki)->toContain('docker logs -n ')
|
||||
->and($helsinki)->not->toContain('--since');
|
||||
});
|
||||
@@ -3,17 +3,20 @@
|
||||
use App\Livewire\Project\Shared\Logs;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function runtimeLogsApplication(): array
|
||||
{
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
$user = User::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
@@ -84,3 +87,100 @@ it('keeps the container lookup error instead of reporting no running containers'
|
||||
->and($html)->toContain('No query results for model [App\\Models\\PrivateKey]')
|
||||
->and($html)->not->toContain('No containers are running');
|
||||
});
|
||||
|
||||
it('shows one log viewer for the selected deployment instead of an accordion', function () {
|
||||
[$team, $server, $application] = runtimeLogsApplication();
|
||||
$edge = Server::factory()->create(['team_id' => $team->id, 'name' => 'Edge']);
|
||||
|
||||
$component = Livewire::test(Logs::class)
|
||||
->set('resource', $application)
|
||||
->set('type', 'application')
|
||||
->set('servers', collect([$server, $edge]))
|
||||
->set('serverContainers', [
|
||||
$server->id => [
|
||||
['ID' => 'app', 'Names' => 'app', 'State' => 'running', 'Labels' => 'coolify.pullRequestId=0,com.docker.compose.service=web'],
|
||||
['ID' => 'preview-web', 'Names' => 'app-pr-12-web', 'State' => 'running', 'Labels' => 'coolify.pullRequestId=12,com.docker.compose.service=web'],
|
||||
['ID' => 'preview-api', 'Names' => 'app-pr-12-api', 'State' => 'running', 'Labels' => 'coolify.pullRequestId=12,com.docker.compose.service=api'],
|
||||
],
|
||||
$edge->id => [
|
||||
['ID' => 'edge-app', 'Names' => 'edge-app', 'State' => 'running', 'Labels' => 'coolify.pullRequestId=0,com.docker.compose.service=web'],
|
||||
],
|
||||
])
|
||||
->set('containersLoaded', true)
|
||||
->set('pullRequestId', '12');
|
||||
|
||||
$component->assertSet('service', 'api')
|
||||
->assertDontSee('Search servers')
|
||||
->assertSee('Search deployments')
|
||||
->assertSee('PR #12')
|
||||
->assertDontSee('Preview #12')
|
||||
->assertSee('Search services')
|
||||
->assertDontSee('runtime-log-trigger');
|
||||
|
||||
expect(runtimeLogViewerCount($component->html()))->toBe(1);
|
||||
});
|
||||
|
||||
it('hides server and service selectors when production is the only container', function () {
|
||||
[, $server, $application] = runtimeLogsApplication();
|
||||
|
||||
$component = Livewire::test(Logs::class)
|
||||
->set('resource', $application)
|
||||
->set('type', 'application')
|
||||
->set('servers', collect([$server]))
|
||||
->set('serverContainers', [
|
||||
$server->id => [
|
||||
['ID' => 'app', 'Names' => 'app', 'State' => 'running', 'Labels' => 'coolify.pullRequestId=0'],
|
||||
],
|
||||
])
|
||||
->set('containersLoaded', true)
|
||||
->call('syncLogSelection');
|
||||
|
||||
$component->assertSet('service', '')
|
||||
->assertDontSee('Search servers')
|
||||
->assertDontSee('Search deployments')
|
||||
->assertDontSee('Search services')
|
||||
->assertDontSee('runtime-log-trigger');
|
||||
|
||||
expect(runtimeLogViewerCount($component->html()))->toBe(1);
|
||||
});
|
||||
|
||||
it('merges every container of a service and does not offer a server selector', function () {
|
||||
[$team, $server, $application] = runtimeLogsApplication();
|
||||
$server->update(['name' => 'Frankfurt']);
|
||||
$edge = Server::factory()->create(['team_id' => $team->id, 'name' => 'Helsinki']);
|
||||
|
||||
$component = Livewire::test(Logs::class)
|
||||
->set('resource', $application)
|
||||
->set('type', 'application')
|
||||
->set('servers', collect([$server, $edge]))
|
||||
->set('serverContainers', [
|
||||
$server->id => [
|
||||
['ID' => 'web-new', 'Names' => 'web-new', 'State' => 'running', 'Labels' => 'coolify.pullRequestId=0,com.docker.compose.service=web'],
|
||||
['ID' => 'web-old', 'Names' => 'web-old', 'State' => 'exited', 'Labels' => 'coolify.pullRequestId=0,com.docker.compose.service=web'],
|
||||
],
|
||||
$edge->id => [
|
||||
['ID' => 'web-edge', 'Names' => 'web-edge', 'State' => 'running', 'Labels' => 'coolify.pullRequestId=0,com.docker.compose.service=web'],
|
||||
],
|
||||
])
|
||||
->set('containersLoaded', true)
|
||||
->call('syncLogSelection');
|
||||
|
||||
$component->assertSet('service', '')
|
||||
->assertDontSee('Search servers')
|
||||
->assertDontSee('runtime-log-trigger');
|
||||
|
||||
expect($component->instance()->render()->getData()['targets']['sources'])->toBe([
|
||||
['server' => $server->uuid, 'container' => 'web-new', 'label' => 'Frankfurt web-new'],
|
||||
['server' => $server->uuid, 'container' => 'web-old', 'label' => 'Frankfurt web-old'],
|
||||
['server' => $edge->uuid, 'container' => 'web-edge', 'label' => 'Helsinki'],
|
||||
]);
|
||||
|
||||
expect(runtimeLogViewerCount($component->html()))->toBe(1);
|
||||
});
|
||||
|
||||
function runtimeLogViewerCount(string $html): int
|
||||
{
|
||||
$workspace = (string) str($html)->after('class="runtime-logs-workspace"')->before('</section>');
|
||||
|
||||
return preg_match_all('/project\.shared\.get-logs|<div wire:id="/', $workspace);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
use App\Support\RuntimeLogTargets;
|
||||
|
||||
function runtimeLogServer(string $uuid, string $name, array $containers): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $uuid,
|
||||
'name' => $name,
|
||||
'containers' => $containers,
|
||||
];
|
||||
}
|
||||
|
||||
function runtimeLogContainer(string $name, string $labels, string $state = 'running', string $id = ''): array
|
||||
{
|
||||
return [
|
||||
'ID' => $id !== '' ? $id : $name,
|
||||
'Names' => $name,
|
||||
'State' => $state,
|
||||
'Labels' => $labels,
|
||||
];
|
||||
}
|
||||
|
||||
function runtimeLogTargets(array $servers, string $deployment = '0', string $service = '', bool $groupDeployments = true): array
|
||||
{
|
||||
return RuntimeLogTargets::select($servers, $deployment, $service, $groupDeployments);
|
||||
}
|
||||
|
||||
it('orders production ahead of newer previews and uses the top bar pills', function () {
|
||||
$targets = runtimeLogTargets([
|
||||
runtimeLogServer('primary', 'Primary', [
|
||||
runtimeLogContainer('app-pr-7', 'coolify.pullRequestId=7,com.docker.compose.service=web'),
|
||||
runtimeLogContainer('app', 'coolify.pullRequestId=0,com.docker.compose.service=web'),
|
||||
runtimeLogContainer('app-pr-42', 'coolify.pullRequestId=42,com.docker.compose.service=web'),
|
||||
]),
|
||||
]);
|
||||
|
||||
expect($targets['deployments'])->toBe([
|
||||
['value' => '0', 'label' => 'Production', 'labelClass' => 'deployment-badge deployment-badge-production'],
|
||||
['value' => '42', 'label' => 'PR #42', 'labelClass' => 'deployment-badge deployment-badge-preview'],
|
||||
['value' => '7', 'label' => 'PR #7', 'labelClass' => 'deployment-badge deployment-badge-preview'],
|
||||
])
|
||||
->and($targets['deployment'])->toBe('0')
|
||||
->and($targets['service'])->toBe('')
|
||||
->and($targets['sources'][0]['container'])->toBe('app')
|
||||
->and($targets)->not->toHaveKey('servers');
|
||||
});
|
||||
|
||||
it('treats a missing pull request label as production', function () {
|
||||
$targets = runtimeLogTargets([
|
||||
runtimeLogServer('primary', 'Primary', [
|
||||
runtimeLogContainer('app', 'com.docker.compose.service=web'),
|
||||
runtimeLogContainer('app-pr-3', 'coolify.pullRequestId=3,com.docker.compose.service=web'),
|
||||
]),
|
||||
]);
|
||||
|
||||
expect($targets['deployments'][0]['label'])->toBe('Production')
|
||||
->and($targets['sources'][0]['container'])->toBe('app');
|
||||
});
|
||||
|
||||
it('uses the preview from the query when that deployment exists', function () {
|
||||
$targets = runtimeLogTargets([
|
||||
runtimeLogServer('primary', 'Primary', [
|
||||
runtimeLogContainer('app', 'coolify.pullRequestId=0,com.docker.compose.service=web'),
|
||||
runtimeLogContainer('app-pr-12', 'coolify.pullRequestId=12,com.docker.compose.service=web'),
|
||||
]),
|
||||
], '12');
|
||||
|
||||
expect($targets['deployment'])->toBe('12')
|
||||
->and($targets['sources'][0]['container'])->toBe('app-pr-12');
|
||||
});
|
||||
|
||||
it('falls back to production when the requested preview is gone', function () {
|
||||
$targets = runtimeLogTargets([
|
||||
runtimeLogServer('primary', 'Primary', [
|
||||
runtimeLogContainer('app', 'coolify.pullRequestId=0,com.docker.compose.service=web'),
|
||||
runtimeLogContainer('app-pr-7', 'coolify.pullRequestId=7,com.docker.compose.service=web'),
|
||||
]),
|
||||
], '99');
|
||||
|
||||
expect($targets['deployment'])->toBe('0')
|
||||
->and($targets['sources'][0]['container'])->toBe('app');
|
||||
});
|
||||
|
||||
it('falls back to the newest preview when production has no container', function () {
|
||||
$targets = runtimeLogTargets([
|
||||
runtimeLogServer('primary', 'Primary', [
|
||||
runtimeLogContainer('app-pr-3', 'coolify.pullRequestId=3,com.docker.compose.service=web'),
|
||||
runtimeLogContainer('app-pr-12', 'coolify.pullRequestId=12,com.docker.compose.service=web'),
|
||||
]),
|
||||
]);
|
||||
|
||||
expect($targets['deployment'])->toBe('12')
|
||||
->and($targets['sources'][0]['container'])->toBe('app-pr-12');
|
||||
});
|
||||
|
||||
it('names services from the compose label, then coolify.name, then the container headline', function () {
|
||||
$targets = runtimeLogTargets([
|
||||
runtimeLogServer('primary', 'Primary', [
|
||||
runtimeLogContainer('compose-name', 'com.docker.compose.service=api,coolify.name=ignored'),
|
||||
runtimeLogContainer('labeled-name', 'coolify.name=worker'),
|
||||
runtimeLogContainer('plain-container-1', ''),
|
||||
]),
|
||||
], '0', '', false);
|
||||
|
||||
expect(array_column($targets['services'], 'label'))->toBe(['api', 'Plain Container', 'worker'])
|
||||
->and($targets['service'])->toBe('api')
|
||||
->and($targets['sources'][0]['container'])->toBe('compose-name');
|
||||
});
|
||||
|
||||
it('gives each service one option and colors it from the container states', function (array $states, string $dot, ?string $description, ?string $stateLabel) {
|
||||
$containers = [];
|
||||
foreach ($states as $index => $state) {
|
||||
$containers[] = runtimeLogContainer('web-'.$index, 'com.docker.compose.service=web', $state);
|
||||
}
|
||||
|
||||
$option = runtimeLogTargets([
|
||||
runtimeLogServer('primary', 'Primary', $containers),
|
||||
])['services'][0];
|
||||
|
||||
expect($option['label'])->toBe('web')
|
||||
->and($option['value'])->toBe('web')
|
||||
->and($option['dot'])->toBe($dot)
|
||||
->and($option['description'] ?? null)->toBe($description)
|
||||
->and($option['state'] ?? null)->toBe($stateLabel);
|
||||
})->with([
|
||||
'running' => [['running'], 'success', null, null],
|
||||
'starting' => [['starting'], 'warning', null, 'Starting'],
|
||||
'restarting' => [['running', 'restarting'], 'warning', '2 containers', 'Restarting'],
|
||||
'mixed' => [['running', 'exited'], 'warning', '2 containers', 'Degraded'],
|
||||
'stopped' => [['exited', 'dead'], 'error', '2 containers', 'Stopped'],
|
||||
]);
|
||||
|
||||
it('defaults to the first running service and keeps the requested service key', function () {
|
||||
$servers = [
|
||||
runtimeLogServer('primary', 'Primary', [
|
||||
runtimeLogContainer('api', 'com.docker.compose.service=api', 'exited'),
|
||||
runtimeLogContainer('web-old', 'com.docker.compose.service=web', 'exited'),
|
||||
runtimeLogContainer('web-new', 'com.docker.compose.service=web', 'running'),
|
||||
runtimeLogContainer('worker', 'com.docker.compose.service=worker', 'running'),
|
||||
]),
|
||||
];
|
||||
|
||||
$targets = runtimeLogTargets($servers);
|
||||
|
||||
expect(array_column($targets['services'], 'label'))->toBe(['api', 'web', 'worker'])
|
||||
->and($targets['services'][0]['dot'])->toBe('error')
|
||||
->and($targets['services'][0]['state'])->toBe('Stopped')
|
||||
->and($targets['services'][1]['dot'])->toBe('warning')
|
||||
->and($targets['services'][1]['state'])->toBe('Degraded')
|
||||
->and($targets['services'][1]['description'])->toBe('2 containers')
|
||||
->and($targets['services'][2]['state'] ?? null)->toBeNull()
|
||||
->and($targets['service'])->toBe('web')
|
||||
->and(array_column($targets['sources'], 'container'))->toBe(['web-new', 'web-old']);
|
||||
|
||||
$requested = runtimeLogTargets($servers, '0', 'worker');
|
||||
|
||||
expect($requested['service'])->toBe('worker')
|
||||
->and($requested['sources'][0]['container'])->toBe('worker');
|
||||
});
|
||||
|
||||
it('merges a service across servers and a rollout duplicate', function () {
|
||||
$targets = runtimeLogTargets([
|
||||
runtimeLogServer('primary', 'Frankfurt', [
|
||||
runtimeLogContainer('web-new', 'com.docker.compose.service=web', 'running'),
|
||||
runtimeLogContainer('web-old', 'com.docker.compose.service=web', 'exited'),
|
||||
runtimeLogContainer('api', 'com.docker.compose.service=api', 'running'),
|
||||
]),
|
||||
runtimeLogServer('edge', 'Helsinki', [
|
||||
runtimeLogContainer('web-edge', 'com.docker.compose.service=web', 'running'),
|
||||
]),
|
||||
], '0', 'web');
|
||||
|
||||
expect($targets)->not->toHaveKey('servers')
|
||||
->and($targets['sources'])->toBe([
|
||||
['server' => 'primary', 'container' => 'web-new', 'label' => 'Frankfurt web-new'],
|
||||
['server' => 'primary', 'container' => 'web-old', 'label' => 'Frankfurt web-old'],
|
||||
['server' => 'edge', 'container' => 'web-edge', 'label' => 'Helsinki'],
|
||||
])
|
||||
->and($targets['streamKey'])->toBe('primary:web-new|primary:web-old|edge:web-edge');
|
||||
});
|
||||
|
||||
it('opens a preview that exists on only one server', function () {
|
||||
$targets = runtimeLogTargets([
|
||||
runtimeLogServer('primary', 'Primary', [
|
||||
runtimeLogContainer('primary-app', 'coolify.pullRequestId=0'),
|
||||
]),
|
||||
runtimeLogServer('edge', 'Edge', [
|
||||
runtimeLogContainer('edge-pr-8', 'coolify.pullRequestId=8'),
|
||||
]),
|
||||
], '8');
|
||||
|
||||
expect($targets['deployment'])->toBe('8')
|
||||
->and($targets['sources'])->toBe([
|
||||
['server' => 'edge', 'container' => 'edge-pr-8', 'label' => 'edge-pr-8'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not split one-click services into preview deployments', function () {
|
||||
$targets = runtimeLogTargets([
|
||||
runtimeLogServer('primary', 'Primary', [
|
||||
runtimeLogContainer('app-service', 'coolify.pullRequestId=4,com.docker.compose.service=app'),
|
||||
runtimeLogContainer('db-service', 'coolify.pullRequestId=4,com.docker.compose.service=db'),
|
||||
]),
|
||||
], '4', '', false);
|
||||
|
||||
expect($targets['deployments'])->toBe([])
|
||||
->and($targets['deployment'])->toBe('0')
|
||||
->and(array_column($targets['services'], 'label'))->toBe(['app', 'db']);
|
||||
});
|
||||
|
||||
it('reads labels given as an array', function () {
|
||||
$targets = runtimeLogTargets([
|
||||
runtimeLogServer('primary', 'Primary', [
|
||||
[
|
||||
'ID' => 'app-id',
|
||||
'Names' => 'app',
|
||||
'State' => 'running',
|
||||
'Labels' => [
|
||||
'coolify.pullRequestId' => '0',
|
||||
'com.docker.compose.service' => 'web',
|
||||
],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
expect($targets['sources'][0]['container'])->toBe('app')
|
||||
->and($targets['services'][0]['label'])->toBe('web');
|
||||
});
|
||||
|
||||
it('drops containers whose names cannot be passed to docker logs', function () {
|
||||
$targets = runtimeLogTargets([
|
||||
runtimeLogServer('primary', 'Primary', [
|
||||
runtimeLogContainer('valid-name', 'com.docker.compose.service=web'),
|
||||
runtimeLogContainer('bad name', 'com.docker.compose.service=api'),
|
||||
]),
|
||||
]);
|
||||
|
||||
expect(array_column($targets['services'], 'value'))->toBe(['web']);
|
||||
});
|
||||
|
||||
it('returns an empty selection when nothing is running', function () {
|
||||
$targets = runtimeLogTargets([
|
||||
runtimeLogServer('primary', 'Primary', []),
|
||||
], '12', 'web');
|
||||
|
||||
expect($targets['sources'])->toBe([])
|
||||
->and($targets['services'])->toBe([])
|
||||
->and($targets['streamKey'])->toBe('');
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Process\PendingProcess;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('app.maintenance.store', 'array');
|
||||
$this->stack = seedBrowserResourceStack();
|
||||
});
|
||||
|
||||
it('keeps service options left aligned beside their status dot', function () {
|
||||
$application = createBrowserApplication($this->stack, ['uuid' => 'app-runtime-menu']);
|
||||
$containers = implode("\n", [
|
||||
json_encode(['ID' => 'api', 'Names' => 'api', 'State' => 'exited', 'Labels' => 'coolify.pullRequestId=0,com.docker.compose.service=api']),
|
||||
json_encode(['ID' => 'web-new', 'Names' => 'web-new', 'State' => 'running', 'Labels' => 'coolify.pullRequestId=0,com.docker.compose.service=web']),
|
||||
json_encode(['ID' => 'web-old', 'Names' => 'web-old', 'State' => 'exited', 'Labels' => 'coolify.pullRequestId=0,com.docker.compose.service=web']),
|
||||
json_encode(['ID' => 'worker', 'Names' => 'worker', 'State' => 'running', 'Labels' => 'coolify.pullRequestId=0,com.docker.compose.service=worker']),
|
||||
]);
|
||||
Process::fake(function (PendingProcess $process) use ($containers) {
|
||||
$command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
|
||||
if (str_contains($command, 'docker logs')) {
|
||||
return Process::result(output: "2026-09-28T10:00:01Z web log line 01\n");
|
||||
}
|
||||
if (str_contains($command, 'docker ps')) {
|
||||
return Process::result(output: $containers."\n");
|
||||
}
|
||||
|
||||
return Process::result();
|
||||
});
|
||||
|
||||
$url = applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $application).'/logs';
|
||||
$page = visit('/login')
|
||||
->fill('email', 'test@example.com')
|
||||
->fill('password', 'password')
|
||||
->click('Login')
|
||||
->assertSee('Dashboard');
|
||||
|
||||
$page->resize(1440, 960)
|
||||
->navigate($url)
|
||||
->assertSee('web log line 01');
|
||||
|
||||
$page->script("document.querySelector('[aria-label=\"Dismiss notifications reminder\"]')?.click()");
|
||||
$page->click('#service-trigger')
|
||||
->assertSee('Stopped')
|
||||
->assertSee('Degraded')
|
||||
->assertSee('2 containers')
|
||||
->assertSee('worker');
|
||||
|
||||
expect($page->script(<<<'JS'
|
||||
(() => {
|
||||
const options = [...document.querySelectorAll('#service-panel .listbox-option')];
|
||||
if (options.length < 3) return false;
|
||||
const stateText = (option) => [...option.querySelectorAll('span')]
|
||||
.map((element) => element.textContent.trim())
|
||||
.find((text) => ['Stopped', 'Degraded', 'Starting', 'Restarting'].includes(text)) ?? '';
|
||||
return options.every((option) => {
|
||||
const label = option.querySelector('span.truncate');
|
||||
if (!label) return false;
|
||||
const optionLeft = option.getBoundingClientRect().left;
|
||||
const labelLeft = label.getBoundingClientRect().left;
|
||||
const optionRight = option.getBoundingClientRect().right;
|
||||
const name = label.textContent.trim();
|
||||
const state = stateText(option);
|
||||
const expected = name === 'api' ? 'Stopped' : (name === 'web' ? 'Degraded' : '');
|
||||
return labelLeft - optionLeft < 40 && labelLeft < (optionLeft + optionRight) / 2 && state === expected;
|
||||
});
|
||||
})()
|
||||
JS))->toBeTrue();
|
||||
|
||||
$page->screenshot(fullPage: false, filename: 'runtime_logs_service_menu');
|
||||
});
|
||||
@@ -56,10 +56,11 @@ it('searches all loaded lines and highlights matches', function () {
|
||||
->fill('email', 'test@example.com')
|
||||
->fill('password', 'password')
|
||||
->click('Login')
|
||||
->assertSee('Dashboard')
|
||||
->navigate("/server/{$this->stack['server']->uuid}/sentinel/logs");
|
||||
->assertSee('Dashboard');
|
||||
|
||||
$page->assertSee('log line 00001')
|
||||
$page->script('localStorage.setItem("coolify-log-text-view", "false")');
|
||||
$page->navigate("/server/{$this->stack['server']->uuid}/sentinel/logs")
|
||||
->assertSee('log line 00001')
|
||||
->type('input[aria-label="Find in logs"]', 'line 4999')
|
||||
->wait(0.6)
|
||||
->assertSee('log line 49990')
|
||||
@@ -145,36 +146,52 @@ it('falls back to the default level filters when the saved filters are invalid',
|
||||
$page->screenshot(filename: 'runtime-logs-invalid-saved-filters');
|
||||
});
|
||||
|
||||
it('shows compact text lines in text view and remembers the choice', function () {
|
||||
it('opens runtime logs in text view and remembers the choice', function () {
|
||||
$page = visit('/login')
|
||||
->fill('email', 'test@example.com')
|
||||
->fill('password', 'password')
|
||||
->click('Login')
|
||||
->assertSee('Dashboard')
|
||||
->navigate("/server/{$this->stack['server']->uuid}/sentinel/logs");
|
||||
->assertSee('Dashboard');
|
||||
|
||||
$page->assertSee('log line 00001')
|
||||
->click('[title="View Options"]')
|
||||
->click('button:visible:has-text("Text view")')
|
||||
->wait(0.3);
|
||||
$page->script('localStorage.removeItem("coolify-log-text-view")');
|
||||
$page->navigate("/server/{$this->stack['server']->uuid}/sentinel/logs")
|
||||
->assertSee('log line 00001');
|
||||
|
||||
$page->screenshot(filename: 'runtime-logs-view-menu');
|
||||
expect($page->script('document.getElementById("logs").classList.contains("runtime-log-text-view")'))->toBeTrue()
|
||||
->and($page->script('document.querySelector("[data-log-line]").getBoundingClientRect().height'))->toBeLessThan(24);
|
||||
|
||||
expect($page->script('document.querySelector("[data-log-line]").getBoundingClientRect().height'))->toBeLessThan(24);
|
||||
|
||||
// Rows rendered before the switch are measured again, so no gaps stay between lines.
|
||||
// Rows rendered for the default view sit against each other.
|
||||
expect($page->script('(() => {
|
||||
const rows = [...document.querySelectorAll(".runtime-log-virtual-row")].sort((a, b) => a.dataset.index - b.dataset.index);
|
||||
return rows.slice(1).every((row, i) => row.getBoundingClientRect().top - rows[i].getBoundingClientRect().bottom < 1);
|
||||
})()'))->toBeTrue();
|
||||
|
||||
// Rows do not open details in text view.
|
||||
$page->click('[title="View Options"]')
|
||||
->click('log line 00001')
|
||||
$page->click('log line 00001')
|
||||
->wait(0.3);
|
||||
|
||||
expect($page->script('document.querySelectorAll(".runtime-log-detail").length'))->toBe(0);
|
||||
|
||||
// Turning text view off leaves the grid.
|
||||
$page->click('[title="View Options"]')
|
||||
->click('button:visible:has-text("Text view")')
|
||||
->wait(0.3);
|
||||
|
||||
$page->screenshot(filename: 'runtime-logs-view-menu');
|
||||
|
||||
expect($page->script('document.getElementById("logs").classList.contains("runtime-log-text-view")'))->toBeFalse()
|
||||
->and($page->script('document.querySelector("[data-log-line]").getBoundingClientRect().height'))->toBeGreaterThan(24);
|
||||
|
||||
// A short line shows everything in the row, so it has nothing to expand.
|
||||
$page->click('log line 00001')
|
||||
->wait(0.3);
|
||||
|
||||
expect($page->script('document.querySelectorAll(".runtime-log-detail").length'))->toBe(0)
|
||||
->and($page->script('document.querySelectorAll("[data-log-line][data-expandable]").length'))->toBe(0);
|
||||
|
||||
$page->click('[title="View Options"]')
|
||||
->click('button:visible:has-text("Text view")')
|
||||
->wait(0.3);
|
||||
|
||||
$page->screenshot(filename: 'runtime-logs-text-view');
|
||||
|
||||
$page->navigate("/server/{$this->stack['server']->uuid}/sentinel/logs")
|
||||
@@ -182,3 +199,82 @@ it('shows compact text lines in text view and remembers the choice', function ()
|
||||
|
||||
expect($page->script('document.getElementById("logs").classList.contains("runtime-log-text-view")'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('keeps log text left aligned and the first line against the top of the viewport', function () {
|
||||
$application = createBrowserApplication($this->stack, ['uuid' => 'app-runtime-logs']);
|
||||
$lines = implode("\n", array_map(
|
||||
fn (int $i) => sprintf('2026-09-28T10:00:%02dZ layout line %05d', $i, $i),
|
||||
range(1, 40),
|
||||
));
|
||||
Process::fake(function (PendingProcess $process) use ($lines) {
|
||||
$command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
|
||||
if (str_contains($command, 'docker logs')) {
|
||||
return Process::result(output: $lines);
|
||||
}
|
||||
if (str_contains($command, 'docker ps')) {
|
||||
return Process::result(output: json_encode([
|
||||
'ID' => 'appcontainer',
|
||||
'Names' => 'appcontainer',
|
||||
'State' => 'running',
|
||||
'Labels' => 'coolify.pullRequestId=0,com.docker.compose.service=web',
|
||||
])."\n");
|
||||
}
|
||||
|
||||
return Process::result();
|
||||
});
|
||||
|
||||
$url = applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $application).'/logs';
|
||||
$page = visit('/login')
|
||||
->fill('email', 'test@example.com')
|
||||
->fill('password', 'password')
|
||||
->click('Login')
|
||||
->assertSee('Dashboard');
|
||||
|
||||
$page->script('localStorage.setItem("pageWidth", "full"); localStorage.removeItem("coolify-log-text-view");');
|
||||
$page->resize(1800, 960)
|
||||
->navigate($url)
|
||||
->wait(1)
|
||||
->assertSee('layout line 00001');
|
||||
|
||||
expect($page->script(<<<'JS'
|
||||
(() => {
|
||||
const line = document.querySelector('[data-log-line]');
|
||||
const viewport = document.getElementById('logsContainer');
|
||||
const style = getComputedStyle(line);
|
||||
const topGap = Math.abs(line.getBoundingClientRect().top - viewport.getBoundingClientRect().top);
|
||||
const main = document.querySelector('main');
|
||||
const workspace = document.querySelector('.runtime-logs-workspace');
|
||||
const column = document.querySelector('.runtime-logs-column');
|
||||
const padding = parseFloat(getComputedStyle(main).paddingBottom);
|
||||
const bottomGap = main.getBoundingClientRect().bottom - workspace.getBoundingClientRect().bottom;
|
||||
const widthGap = Math.abs(workspace.getBoundingClientRect().width - column.getBoundingClientRect().width);
|
||||
return viewport.scrollTop === 0 && topGap < 2 && Math.abs(bottomGap - padding) < 2 && widthGap < 2
|
||||
&& style.textAlign !== 'center' && style.alignItems !== 'center';
|
||||
})()
|
||||
JS))->toBeTrue();
|
||||
|
||||
$fullWidth = $page->script('document.querySelector(".runtime-logs-workspace").getBoundingClientRect().width');
|
||||
|
||||
$page->script('localStorage.setItem("pageWidth", "centered")');
|
||||
$page->navigate($url)
|
||||
->assertSee('layout line 00001');
|
||||
|
||||
expect($page->script('document.querySelector(".runtime-logs-workspace").getBoundingClientRect().width'))
|
||||
->toBeLessThan($fullWidth);
|
||||
|
||||
$page->resize(390, 844)
|
||||
->assertSee('layout line 00001');
|
||||
|
||||
expect($page->script(<<<'JS'
|
||||
(() => {
|
||||
const line = document.querySelector('[data-log-line]');
|
||||
const style = getComputedStyle(line);
|
||||
const viewport = document.getElementById('logsContainer');
|
||||
const topGap = Math.abs(line.getBoundingClientRect().top - viewport.getBoundingClientRect().top);
|
||||
return viewport.scrollTop === 0 && topGap < 2
|
||||
&& style.flexDirection === 'column'
|
||||
&& style.alignItems === 'flex-start'
|
||||
&& (style.textAlign === 'left' || style.textAlign === 'start');
|
||||
})()
|
||||
JS))->toBeTrue();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user