From 6f58ad4b97fae14988af030a20b0f10d32cbc6d3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:54:17 +0200 Subject: [PATCH 1/4] fix(backups): keep service DB context after deleting a schedule Cache the related database before deleting the backup so authorization, server lookup, and the service redirect do not touch a deleted morph. Skip rendering after delete and cover the service-database path. --- app/Livewire/Project/Database/BackupEdit.php | 24 ++++----- tests/Feature/BackupEditValidationTest.php | 57 ++++++++++++++++++++ 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 4a709d2b96..4a09fc0463 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -180,7 +180,8 @@ class BackupEdit extends Component public function delete($password, $selectedActions = []) { - $this->authorize('manageBackups', $this->backup->database); + $database = $this->backup->database; + $this->authorize('manageBackups', $database); if (! verifyPasswordConfirmation($password, $this)) { return 'The provided password is incorrect.'; @@ -188,10 +189,10 @@ class BackupEdit extends Component try { $server = null; - if ($this->backup->database instanceof ServiceDatabase) { - $server = $this->backup->database->service->destination->server; - } elseif ($this->backup->database->destination && $this->backup->database->destination->server) { - $server = $this->backup->database->destination->server; + if ($database instanceof ServiceDatabase) { + $server = $database->service->destination->server; + } elseif ($database->destination && $database->destination->server) { + $server = $database->destination->server; } $filenames = $this->backup->executions() @@ -213,15 +214,14 @@ class BackupEdit extends Component } $this->backup->delete(); + $this->skipRender(); - if ($this->backup->database->getMorphClass() === ServiceDatabase::class) { - $serviceDatabase = $this->backup->database; - + if ($database instanceof ServiceDatabase) { return redirectRoute($this, 'project.service.database.backups', [ - 'project_uuid' => $this->parameters['project_uuid'], - 'environment_uuid' => $this->parameters['environment_uuid'], - 'service_uuid' => $serviceDatabase->service->uuid, - 'stack_service_uuid' => $serviceDatabase->uuid, + 'project_uuid' => $database->service->project()->uuid, + 'environment_uuid' => $database->service->environment->uuid, + 'service_uuid' => $database->service->uuid, + 'stack_service_uuid' => $database->uuid, ]); } else { return redirectRoute($this, 'project.database.backup.index', [ diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php index 03af1bea4f..f17d25add8 100644 --- a/tests/Feature/BackupEditValidationTest.php +++ b/tests/Feature/BackupEditValidationTest.php @@ -8,6 +8,8 @@ use App\Models\Project; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\Server; +use App\Models\Service; +use App\Models\ServiceDatabase; use App\Models\StandaloneDocker; use App\Models\StandalonePostgresql; use App\Models\Team; @@ -343,6 +345,61 @@ it('disables S3 backup when saved without a selected S3 storage', function () { expect($backup->s3_storage_id)->toBeNull(); }); +it('deletes a service database backup schedule without rendering the deleted relationship', function () { + InstanceSettings::get()->update(['disable_two_step_confirmation' => true]); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $database = ServiceDatabase::create([ + 'service_id' => $service->id, + 'name' => 'postgres', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'enabled' => true, + 'save_s3' => false, + 'database_backup_retention_amount_locally' => 0, + 'database_backup_retention_days_locally' => 0, + 'database_backup_retention_max_storage_locally' => 0, + 'database_backup_retention_amount_s3' => 0, + 'database_backup_retention_days_s3' => 0, + 'database_backup_retention_max_storage_s3' => 0, + 'dump_all' => false, + 'timeout' => 3600, + 'missing_backup_notification_days' => 0, + 'database_type' => $database->getMorphClass(), + 'database_id' => $database->id, + 'team_id' => $this->team->id, + ]); + $parameters = [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'service_uuid' => $service->uuid, + 'stack_service_uuid' => $database->uuid, + ]; + + $component = Livewire::test(BackupEdit::class, [ + 'backup' => $backup, + 'availableS3Storages' => collect(), + 'section' => 'danger', + ]); + $component + ->call('delete', '') + ->assertRedirectToRoute('project.service.database.backups', $parameters); + + expect(ScheduledDatabaseBackup::find($backup->id))->toBeNull(); +}); + it('cascades to disabling local backup deletion when S3 is force-disabled', function () { $backup = createBackupForEditValidationTest($this->team, [ 'disable_local_backup' => true, From 7d11bc92f7b8eb7430bcd92f3cb652c4d6c8137f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:25:20 +0200 Subject: [PATCH 2/4] feat(logs): accept `all` for log lines and keep `-1` API log endpoints and the GetLogs UI now treat `all` as unbounded output, with `-1` remaining as a compatibility alias. MCP still falls back to a positive default. Traefik version checks run from CheckForUpdatesJob instead of a weekly schedule. --- .ai/lessons.md | 9 ++++ app/Console/Kernel.php | 3 -- .../Api/ApplicationsController.php | 11 +++-- .../Controllers/Api/DatabasesController.php | 11 +++-- .../Api/ServiceApplicationsController.php | 18 ++++++-- .../Api/ServiceDatabasesController.php | 11 ++++- .../Controllers/Api/ServicesController.php | 11 +++-- app/Jobs/CheckForUpdatesJob.php | 2 + app/Livewire/Project/Shared/GetLogs.php | 21 ++++++--- app/Mcp/Concerns/ResolvesResource.php | 4 +- bootstrap/helpers/docker.php | 15 +++++-- .../project/shared/get-logs.blade.php | 6 ++- tests/Feature/CheckForUpdatesTraefikTest.php | 35 +++++++++++++++ tests/Feature/GetLogsCommandInjectionTest.php | 23 ++++++++++ tests/Unit/Api/LogEndpointHelpersTest.php | 45 ++++++++++++++++++- 15 files changed, 186 insertions(+), 39 deletions(-) create mode 100644 tests/Feature/CheckForUpdatesTraefikTest.php 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 @@