diff --git a/.ai/lessons.md b/.ai/lessons.md index 5a2e565f3f..f4c7c22956 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -1,5 +1,10 @@ # Lessons +## Check prior fixes before changing a repeated symptom +- When a reported regression matches a recent fix, inspect that fix and reproduce why it no longer works before adding another workaround. +- Do not claim a redirect or lifecycle root cause from an effects assertion alone. Prove the reported HTTP or browser failure first. +- Preserve SPA navigation when it is a product requirement. Do not replace it with a full-page redirect to mask a deletion race; fix the ordering or state race instead. + ## Confirm which surface becomes the modal - When a user wants two settings pages replaced by a modal, identify the parent page that owns the trigger and confirm that the complete child settings view moves into that modal. - Do not make one child page a modal inside the other child page when the user wants both child URLs removed. @@ -82,3 +87,7 @@ ## Keep modal actions in the footer - When a modal has a large editable body, put preview, validation, and save controls in a fixed footer. Keep the title bar for the title and close action. + +## Prefer named API values over numeric sentinels +- When an API option means an unbounded or special mode, expose a clear named value such as `all`. +- Keep an existing numeric sentinel such as `-1` only as a compatibility alias unless the user requests a breaking change. diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 79660c2490..a86b26ce5c 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -6,7 +6,6 @@ use App\Jobs\ApiTokenExpirationWarningJob; use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckHelperImageJob; use App\Jobs\CheckMissingDatabaseBackupsJob; -use App\Jobs\CheckTraefikVersionJob; use App\Jobs\CleanupInstanceStuffsJob; use App\Jobs\CleanupOrphanedPreviewContainersJob; use App\Jobs\CleanupStaleMultiplexedConnections; @@ -91,8 +90,6 @@ class Kernel extends ConsoleKernel $this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily()->onOneServer(); - $this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer(); - $this->scheduleInstance->command('cleanup:database --yes')->daily(); $this->scheduleInstance->command('uploads:clear')->everyTwoMinutes(); diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 67fd515bcd..bf68713e94 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2410,13 +2410,12 @@ class ApplicationsController extends Controller new OA\Parameter( name: 'lines', in: 'query', - description: 'Number of lines to show from the end of the logs.', + description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.', required: false, - schema: new OA\Schema( - type: 'integer', - format: 'int32', - default: 100, - ) + schema: new OA\Schema(oneOf: [ + new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000), + new OA\Schema(type: 'string', enum: ['all']), + ]) ), new OA\Parameter( name: 'show_timestamps', diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 881aa5a897..c9b28e5a2f 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2433,13 +2433,12 @@ class DatabasesController extends Controller new OA\Parameter( name: 'lines', in: 'query', - description: 'Number of lines to show from the end of the logs.', + description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.', required: false, - schema: new OA\Schema( - type: 'integer', - format: 'int32', - default: 100, - ) + schema: new OA\Schema(oneOf: [ + new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000), + new OA\Schema(type: 'string', enum: ['all']), + ]) ), new OA\Parameter( name: 'show_timestamps', diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index c827e818d5..dda70c27eb 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -402,9 +402,12 @@ class ServiceApplicationsController extends Controller new OA\Parameter( name: 'lines', in: 'query', - description: 'Number of lines to show from the end of the logs.', + description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.', required: false, - schema: new OA\Schema(type: 'integer', format: 'int32', default: 100) + schema: new OA\Schema(oneOf: [ + new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000), + new OA\Schema(type: 'string', enum: ['all']), + ]) ), ], responses: [ @@ -451,7 +454,16 @@ class ServiceApplicationsController extends Controller parameters: [ new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), - new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.', + required: false, + schema: new OA\Schema(oneOf: [ + new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000), + new OA\Schema(type: 'string', enum: ['all']), + ]), + ), ], responses: [ new OA\Response( diff --git a/app/Http/Controllers/Api/ServiceDatabasesController.php b/app/Http/Controllers/Api/ServiceDatabasesController.php index 480ff4e557..1304e0d22b 100644 --- a/app/Http/Controllers/Api/ServiceDatabasesController.php +++ b/app/Http/Controllers/Api/ServiceDatabasesController.php @@ -288,7 +288,16 @@ class ServiceDatabasesController extends Controller parameters: [ new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), - new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.', + required: false, + schema: new OA\Schema(oneOf: [ + new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000), + new OA\Schema(type: 'string', enum: ['all']), + ]), + ), ], responses: [ new OA\Response(response: 200, description: 'Logs.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'logs', type: 'string')])), diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 1a199c07ec..5763f3c276 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -869,13 +869,12 @@ class ServicesController extends Controller new OA\Parameter( name: 'lines', in: 'query', - description: 'Number of lines to show from the end of the logs.', + description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.', required: false, - schema: new OA\Schema( - type: 'integer', - format: 'int32', - default: 100, - ) + schema: new OA\Schema(oneOf: [ + new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000), + new OA\Schema(type: 'string', enum: ['all']), + ]) ), new OA\Parameter( name: 'show_timestamps', diff --git a/app/Jobs/CheckForUpdatesJob.php b/app/Jobs/CheckForUpdatesJob.php index 8da2426da7..b4cb7fe705 100644 --- a/app/Jobs/CheckForUpdatesJob.php +++ b/app/Jobs/CheckForUpdatesJob.php @@ -73,6 +73,8 @@ class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue // Invalidate cache to ensure fresh data is loaded invalidate_versions_cache(); + CheckTraefikVersionJob::dispatch(); + // Only mark new version available if Coolify version actually increased if (version_compare($latest_version, $current_version, '>')) { // New version available diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index e1e5413719..3da0c75876 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -131,6 +131,12 @@ class GetLogs extends Component $this->streamLogs = ! $this->streamLogs; } + public function showAllLogs(): void + { + $this->numberOfLines = -1; + $this->getLogs(true); + } + public function getLogs($refresh = false) { if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) { @@ -149,22 +155,25 @@ class GetLogs extends Component if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === Service::class || str($this->container)->contains('-pr-'))) { return; } - if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) { + $logTail = $this->numberOfLines === -1 ? 'all' : $this->numberOfLines; + if ($logTail !== 'all' && ($logTail <= 0 || is_null($logTail))) { $this->numberOfLines = 1000; + $logTail = $this->numberOfLines; } - if ($this->numberOfLines > self::MAX_LOG_LINES) { + if ($logTail !== 'all' && $logTail > self::MAX_LOG_LINES) { $this->numberOfLines = self::MAX_LOG_LINES; + $logTail = $this->numberOfLines; } if ($this->container) { if ($this->showTimeStamps) { if ($this->server->isSwarm()) { - $command = "docker service logs -n {$this->numberOfLines} -t {$this->container}"; + $command = "docker service logs -n {$logTail} -t {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } } else { - $command = "docker logs -n {$this->numberOfLines} -t {$this->container}"; + $command = "docker logs -n {$logTail} -t {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; @@ -172,13 +181,13 @@ class GetLogs extends Component } } else { if ($this->server->isSwarm()) { - $command = "docker service logs -n {$this->numberOfLines} {$this->container}"; + $command = "docker service logs -n {$logTail} {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } } else { - $command = "docker logs -n {$this->numberOfLines} {$this->container}"; + $command = "docker logs -n {$logTail} {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; diff --git a/app/Mcp/Concerns/ResolvesResource.php b/app/Mcp/Concerns/ResolvesResource.php index 7695fb9e25..eb002234a8 100644 --- a/app/Mcp/Concerns/ResolvesResource.php +++ b/app/Mcp/Concerns/ResolvesResource.php @@ -98,6 +98,8 @@ trait ResolvesResource */ protected function normalizeMcpLogLines(mixed $lines): int { - return normalizeLogLines($lines, default: 100, max: 500); + $lines = normalizeLogLines($lines, default: 100, max: 500); + + return is_int($lines) && $lines > 0 ? $lines : 100; } } diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 613a104e0e..02dee81b70 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1540,10 +1540,17 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable } } -function normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int +function normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int|string { + if ($lines === 'all') { + return 'all'; + } + $lines = filter_var($lines, FILTER_VALIDATE_INT); - if ($lines === false || $lines <= 0) { + if ($lines === -1) { + return 'all'; + } + if ($lines === false || $lines < -1) { return $default; } @@ -1555,7 +1562,7 @@ function parseLogTimestampFlag(mixed $showTimestamps): bool return filter_var($showTimestamps, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false; } -function buildContainerLogsCommand(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string +function buildContainerLogsCommand(Server $server, string $container_id, int|string $lines = 100, bool $showTimestamps = false): string { $command = "docker logs -n {$lines}"; if ($server->isSwarm()) { @@ -1569,7 +1576,7 @@ function buildContainerLogsCommand(Server $server, string $container_id, int $li return "{$command} ".escapeshellarg($container_id).' 2>&1'; } -function getContainerLogs(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string +function getContainerLogs(Server $server, string $container_id, int|string $lines = 100, bool $showTimestamps = false): string { $output = instant_remote_process([buildContainerLogsCommand($server, $container_id, $lines, $showTimestamps)], $server); $output = removeAnsiColors($output); diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php index b5e9c21039..ec924cd80a 100644 --- a/resources/views/livewire/project/shared/get-logs.blade.php +++ b/resources/views/livewire/project/shared/get-logs.blade.php @@ -484,9 +484,11 @@
Lines - +
diff --git a/tests/Feature/CheckForUpdatesTraefikTest.php b/tests/Feature/CheckForUpdatesTraefikTest.php new file mode 100644 index 0000000000..ac53a5a8cb --- /dev/null +++ b/tests/Feature/CheckForUpdatesTraefikTest.php @@ -0,0 +1,35 @@ + Http::response([ + 'coolify' => ['v4' => ['version' => '4.0.10']], + 'traefik' => ['v3.7' => '3.7.13'], + ]), + ]); + File::shouldReceive('exists')->andReturn(false); + File::shouldReceive('put')->once(); + + InstanceSettings::forceCreate(['id' => 0]); + + config([ + 'app.env' => 'production', + 'constants.coolify.self_hosted' => true, + 'constants.coolify.version' => '4.0.10', + ]); + + (new CheckForUpdatesJob)->handle(); + + Bus::assertDispatched(CheckTraefikVersionJob::class); +}); diff --git a/tests/Feature/GetLogsCommandInjectionTest.php b/tests/Feature/GetLogsCommandInjectionTest.php index 2c86d73284..f7aac5fcb4 100644 --- a/tests/Feature/GetLogsCommandInjectionTest.php +++ b/tests/Feature/GetLogsCommandInjectionTest.php @@ -74,6 +74,29 @@ describe('GetLogs locked properties', function () { }); describe('GetLogs Livewire action validation', function () { + test('getLogs requests all logs when the line count is minus one', function () { + $this->server->settings->fill([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ])->save(); + $server = Server::with('settings')->findOrFail($this->server->id); + + Process::fake(['*' => Process::result(output: 'all logs')]); + + Livewire::test(GetLogs::class, [ + 'server' => $server, + 'resource' => $this->application, + 'container' => 'test-container', + ]) + ->assertSee('All') + ->assertSeeHtml('title="Show all logs"') + ->call('showAllLogs') + ->assertSet('numberOfLines', -1); + + Process::assertRan(fn ($process) => str_contains($process->command, 'docker logs -n all')); + }); + test('getLogs marks ANSI-colored output truncated based on raw bytes', function () { $this->server->settings->fill([ 'is_reachable' => true, diff --git a/tests/Unit/Api/LogEndpointHelpersTest.php b/tests/Unit/Api/LogEndpointHelpersTest.php index c4173bd699..e298def5dd 100644 --- a/tests/Unit/Api/LogEndpointHelpersTest.php +++ b/tests/Unit/Api/LogEndpointHelpersTest.php @@ -1,5 +1,6 @@ toBe(100) ->and(normalizeLogLines(''))->toBe(100) ->and(normalizeLogLines('abc'))->toBe(100) - ->and(normalizeLogLines('0'))->toBe(100) + ->and(normalizeLogLines('all'))->toBe('all') + ->and(normalizeLogLines('ALL'))->toBe(100) + ->and(normalizeLogLines('-1'))->toBe('all') + ->and(normalizeLogLines('0'))->toBe(0) ->and(normalizeLogLines('-5'))->toBe(100) ->and(normalizeLogLines('50'))->toBe(50) ->and(normalizeLogLines('50000'))->toBe(10000); }); +it('keeps MCP log requests bounded', function () { + $normalizer = new class + { + use ResolvesResource; + + public function normalize(mixed $lines): int + { + return $this->normalizeMcpLogLines($lines); + } + }; + + expect($normalizer->normalize('all'))->toBe(100) + ->and($normalizer->normalize('-1'))->toBe(100) + ->and($normalizer->normalize('0'))->toBe(100) + ->and($normalizer->normalize('501'))->toBe(500); +}); + +it('documents the named all logs option on every REST log endpoint', function (string $controller) { + $source = file_get_contents(__DIR__."/../../../app/Http/Controllers/Api/{$controller}.php"); + + expect($source)->toContain('Use `all` to return all logs. `-1` remains available as a compatibility alias.'); +})->with([ + 'applications' => 'ApplicationsController', + 'databases' => 'DatabasesController', + 'services' => 'ServicesController', + 'service applications' => 'ServiceApplicationsController', + 'service databases' => 'ServiceDatabasesController', +]); + it('normalizes service resource log line counts before invoking Docker', function (string $controller, mixed $lines, int $expectedLines) { $source = file_get_contents(__DIR__."/../../../app/Http/Controllers/Api/{$controller}.php"); @@ -63,6 +96,16 @@ it('builds docker log commands with options before an escaped container id', fun ->toBe("docker logs -n 25 --timestamps 'container-1' 2>&1"); }); +it('builds docker log commands for all and zero lines', function () { + $server = new Server; + $server->settings = ['is_swarm_manager' => false]; + + expect(buildContainerLogsCommand($server, 'container-1', 'all')) + ->toBe("docker logs -n all 'container-1' 2>&1") + ->and(buildContainerLogsCommand($server, 'container-1', 0)) + ->toBe("docker logs -n 0 'container-1' 2>&1"); +}); + it('builds swarm service log commands with options before an escaped service id', function () { if (! function_exists('buildContainerLogsCommand')) { expect(function_exists('buildContainerLogsCommand'))->toBeTrue();