mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
304 lines
10 KiB
PHP
304 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Project\Shared;
|
|
|
|
use App\Helpers\SshMultiplexingHelper;
|
|
use App\Models\Application;
|
|
use App\Models\EnvironmentVariable;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Models\ServiceApplication;
|
|
use App\Models\ServiceDatabase;
|
|
use App\Models\StandaloneClickhouse;
|
|
use App\Models\StandaloneDragonfly;
|
|
use App\Models\StandaloneKeydb;
|
|
use App\Models\StandaloneMariadb;
|
|
use App\Models\StandaloneMongodb;
|
|
use App\Models\StandaloneMysql;
|
|
use App\Models\StandalonePostgresql;
|
|
use App\Models\StandaloneRedis;
|
|
use App\Models\StandaloneSqlite;
|
|
use App\Support\ValidationPatterns;
|
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Livewire\Attributes\Locked;
|
|
use Livewire\Attributes\Renderless;
|
|
use Livewire\Component;
|
|
|
|
class GetLogs extends Component
|
|
{
|
|
use AuthorizesRequests;
|
|
|
|
public const MAX_LOG_LINES = 50000;
|
|
|
|
public const MAX_DISPLAY_SIZE_BYTES = 5 * 1024 * 1024;
|
|
|
|
public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
|
|
|
|
/** Docker log timestamp accepted by `--since`, for example 2026-09-28T10:15:30.123456789Z. */
|
|
private const DOCKER_TIMESTAMP_PATTERN = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?Z$/';
|
|
|
|
public string $errors = '';
|
|
|
|
#[Locked]
|
|
public Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite|null $resource = null;
|
|
|
|
#[Locked]
|
|
public ServiceApplication|ServiceDatabase|null $servicesubtype = null;
|
|
|
|
#[Locked]
|
|
public Server $server;
|
|
|
|
#[Locked]
|
|
public ?string $container = null;
|
|
|
|
public ?string $displayName = null;
|
|
|
|
public ?string $pull_request = null;
|
|
|
|
public ?bool $streamLogs = false;
|
|
|
|
public ?bool $showTimeStamps = true;
|
|
|
|
public ?int $numberOfLines = 100;
|
|
|
|
public bool $expandByDefault = false;
|
|
|
|
public bool $collapsible = true;
|
|
|
|
public function mount()
|
|
{
|
|
if (! is_null($this->resource)) {
|
|
if ($this->resource->getMorphClass() === Application::class) {
|
|
$this->showTimeStamps = $this->resource->settings->is_include_timestamps;
|
|
} else {
|
|
if ($this->servicesubtype) {
|
|
$this->showTimeStamps = $this->servicesubtype->is_include_timestamps;
|
|
} else {
|
|
$this->showTimeStamps = $this->resource->is_include_timestamps;
|
|
}
|
|
}
|
|
if ($this->resource?->getMorphClass() === Application::class) {
|
|
if (str($this->container)->contains('-pr-')) {
|
|
$this->pull_request = 'Pull Request: '.str($this->container)->afterLast('-pr-')->beforeLast('_')->value();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public function instantSave()
|
|
{
|
|
if (! is_null($this->resource)) {
|
|
if (auth()->user()->cannot('update', $this->resource)) {
|
|
return;
|
|
}
|
|
|
|
if ($this->resource->getMorphClass() === Application::class) {
|
|
$this->resource->settings->is_include_timestamps = $this->showTimeStamps;
|
|
$this->resource->settings->save();
|
|
}
|
|
if ($this->resource->getMorphClass() === Service::class) {
|
|
$serviceName = str($this->container)->beforeLast('-')->value();
|
|
$subType = $this->resource->applications()->where('name', $serviceName)->first();
|
|
if ($subType) {
|
|
$subType->is_include_timestamps = $this->showTimeStamps;
|
|
$subType->save();
|
|
} else {
|
|
$subType = $this->resource->databases()->where('name', $serviceName)->first();
|
|
if ($subType) {
|
|
$subType->is_include_timestamps = $this->showTimeStamps;
|
|
$subType->save();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public function toggleTimestamps()
|
|
{
|
|
$previousValue = $this->showTimeStamps;
|
|
$this->showTimeStamps = ! $this->showTimeStamps;
|
|
|
|
try {
|
|
$this->instantSave();
|
|
} catch (\Throwable $e) {
|
|
// Revert the flag to its previous value on failure
|
|
$this->showTimeStamps = $previousValue;
|
|
|
|
return handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function toggleStreamLogs()
|
|
{
|
|
$this->streamLogs = ! $this->streamLogs;
|
|
}
|
|
|
|
public function showAllLogs(): string
|
|
{
|
|
$this->numberOfLines = -1;
|
|
|
|
return $this->getLogs();
|
|
}
|
|
|
|
/**
|
|
* Return timestamped log output for the browser-side log viewer.
|
|
*
|
|
* The output is returned instead of stored in a public property, so it is
|
|
* not included in the component snapshot on every request. When `$since`
|
|
* holds a Docker timestamp, only lines from that moment onward are returned.
|
|
*/
|
|
#[Renderless]
|
|
public function getLogs(?string $since = null): string
|
|
{
|
|
if (! $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 (is_string($since) && preg_match(self::DOCKER_TIMESTAMP_PATTERN, $since)) {
|
|
$options = "--since {$since} -t";
|
|
} else {
|
|
$options = "-n {$this->resolveLogTail()} -t";
|
|
}
|
|
|
|
return $this->runLogCommand($this->logCommand($options), self::MAX_DISPLAY_SIZE_BYTES, '5MB');
|
|
}
|
|
|
|
public function copyLogs(): string
|
|
{
|
|
if (! $this->canReadLogs()) {
|
|
return '';
|
|
}
|
|
if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) {
|
|
return '';
|
|
}
|
|
if (! $this->server->isFunctional() || ! $this->container) {
|
|
return '';
|
|
}
|
|
if (! ValidationPatterns::isValidContainerName($this->container)) {
|
|
return '';
|
|
}
|
|
|
|
$options = "-n {$this->resolveLogTail()}".($this->showTimeStamps ? ' -t' : '');
|
|
|
|
return sanitizeLogsForExport($this->runLogCommand($this->logCommand($options), self::MAX_DISPLAY_SIZE_BYTES, '5MB', $this->showTimeStamps), $this->logRedactionSecrets());
|
|
}
|
|
|
|
public function downloadAllLogs(): string
|
|
{
|
|
if (! $this->canReadLogs()) {
|
|
return '';
|
|
}
|
|
if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) {
|
|
return '';
|
|
}
|
|
if (! $this->server->isFunctional() || ! $this->container) {
|
|
return '';
|
|
}
|
|
if (! ValidationPatterns::isValidContainerName($this->container)) {
|
|
return '';
|
|
}
|
|
|
|
$command = $this->logCommand($this->showTimeStamps ? '-t' : '');
|
|
|
|
return sanitizeLogsForExport($this->runLogCommand($command, self::MAX_DOWNLOAD_SIZE_BYTES, '50MB', $this->showTimeStamps), $this->logRedactionSecrets());
|
|
}
|
|
|
|
/**
|
|
* Environment values and database credentials of the resource whose logs are exported.
|
|
*
|
|
* @return array{known: array<int, string>, locked: array<int, string>}
|
|
*/
|
|
private function logRedactionSecrets(): array
|
|
{
|
|
$resource = $this->resource ?? $this->servicesubtype?->service;
|
|
|
|
return EnvironmentVariable::logRedactionSecretsFor($resource, includePreview: $resource instanceof Application && filled($this->pull_request));
|
|
}
|
|
|
|
private function resolveLogTail(): int|string
|
|
{
|
|
if ($this->numberOfLines === -1) {
|
|
return 'all';
|
|
}
|
|
if (is_null($this->numberOfLines) || $this->numberOfLines <= 0) {
|
|
$this->numberOfLines = 1000;
|
|
}
|
|
if ($this->numberOfLines > self::MAX_LOG_LINES) {
|
|
$this->numberOfLines = self::MAX_LOG_LINES;
|
|
}
|
|
|
|
return $this->numberOfLines;
|
|
}
|
|
|
|
private function logCommand(string $options): string
|
|
{
|
|
$options = trim($options);
|
|
$options = $options === '' ? '' : "{$options} ";
|
|
$command = $this->server->isSwarm()
|
|
? "docker service logs {$options}{$this->container}"
|
|
: "docker logs {$options}{$this->container}";
|
|
|
|
if ($this->server->isNonRoot()) {
|
|
$command = parseCommandsByLineForSudo(collect($command), $this->server)[0];
|
|
}
|
|
|
|
return $command;
|
|
}
|
|
|
|
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");
|
|
if ($timestamped) {
|
|
// Docker writes stdout and stderr separately, so sort timestamped lines back into order.
|
|
$logs = str($logs)->split('/\n/')->sort(function ($a, $b) {
|
|
return explode(' ', $a)[0] <=> explode(' ', $b)[0];
|
|
})->join("\n");
|
|
}
|
|
|
|
if ($truncated) {
|
|
$logs .= "\n\n[... Output truncated at {$limitLabel} limit ...]";
|
|
}
|
|
|
|
return $logs;
|
|
}
|
|
|
|
private function boundedLogCommand(string $command, int $maxBytes): string
|
|
{
|
|
return "({$command}) 2>&1 | head -c ".($maxBytes + 1);
|
|
}
|
|
|
|
/**
|
|
* Container output can contain secrets, so only users who can edit the resource (or the server,
|
|
* for server-level containers) may read it.
|
|
*/
|
|
private function canReadLogs(): bool
|
|
{
|
|
return auth()->user()?->can('update', $this->resource ?? $this->server) ?? false;
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
return view('livewire.project.shared.get-logs', [
|
|
'canReadLogs' => $this->canReadLogs(),
|
|
]);
|
|
}
|
|
}
|