From 953726f847599928a097ec36cefab43beb698e85 Mon Sep 17 00:00:00 2001 From: Paulo Matos Date: Wed, 12 Aug 2026 16:19:54 -0300 Subject: [PATCH 01/48] fix: prevent duplicate 2FA challenge submissions causing HTTP 419 --- .../views/auth/two-factor-challenge.blade.php | 15 +++++++- tests/v4/Browser/LoginTest.php | 38 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/resources/views/auth/two-factor-challenge.blade.php b/resources/views/auth/two-factor-challenge.blade.php index 4170b188fe..33c9450181 100644 --- a/resources/views/auth/two-factor-challenge.blade.php +++ b/resources/views/auth/two-factor-challenge.blade.php @@ -2,8 +2,18 @@
Enter one of the recovery codes you saved when setting up two-factor authentication.

-
+ @csrf
@@ -82,7 +93,7 @@
- + Verify and continue diff --git a/tests/v4/Browser/LoginTest.php b/tests/v4/Browser/LoginTest.php index 7a6d6f2b7c..987120e955 100644 --- a/tests/v4/Browser/LoginTest.php +++ b/tests/v4/Browser/LoginTest.php @@ -4,6 +4,7 @@ use App\Models\InstanceSettings; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; +use Laravel\Fortify\Fortify; uses(RefreshDatabase::class); @@ -69,6 +70,43 @@ it('fails login with invalid credentials', function () { ->screenshot(filename: 'login-invalid-credentials'); }); +it('prevents Enter from duplicating the automatic two factor challenge submission', function () { + config(['app.maintenance.driver' => 'file']); + + $user = createRootUser(); + $user->forceFill([ + 'two_factor_secret' => Fortify::currentEncrypter()->encrypt('JBSWY3DPEHPK3PXP'), + 'two_factor_confirmed_at' => now(), + ])->save(); + + $page = visit('/login') + ->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertPathIs('/two-factor-challenge'); + + $page->script(<<<'JS' + window.acceptedTwoFactorSubmissions = 0; + + document.querySelector('form[action="/two-factor-challenge"]').addEventListener('submit', (event) => { + if (!event.defaultPrevented) { + window.acceptedTwoFactorSubmissions++; + } + + event.preventDefault(); + }); + JS); + + foreach (str_split('123456') as $index => $digit) { + $page->keys(sprintf('[aria-label="Digit %d"]', $index + 1), $digit); + } + + $page->keys('[aria-label="Digit 6"]', 'Enter') + ->assertScript('window.acceptedTwoFactorSubmissions', 1) + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'login-two-factor-enter-single-submission'); +}); + /** * Create the root user (id 0) with known credentials for browser login tests. */ From f9f53f2fea1a775c51aae101b02cc3b512c134ff Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Thu, 27 Aug 2026 04:36:12 +0000 Subject: [PATCH 02/48] fix(notifications): implement toWebhook() for always-send notifications WebhookChannel::send() calls toWebhook() unconditionally, but the five notifications reachable through alwaysSendEvents did not implement it, so enabling the webhook channel turned those events into fatal queued jobs. --- .../ApiTokenExpiringNotification.php | 12 ++ .../Internal/GeneralNotification.php | 10 ++ app/Notifications/Server/ForceDisabled.php | 12 ++ app/Notifications/Server/ForceEnabled.php | 12 ++ .../SslExpirationNotification.php | 14 +++ .../Channels/WebhookChannelTest.php | 113 ++++++++++++++++++ 6 files changed, 173 insertions(+) create mode 100644 tests/Unit/Notifications/Channels/WebhookChannelTest.php diff --git a/app/Notifications/ApiTokenExpiringNotification.php b/app/Notifications/ApiTokenExpiringNotification.php index 451dd312a1..c5567a3a64 100644 --- a/app/Notifications/ApiTokenExpiringNotification.php +++ b/app/Notifications/ApiTokenExpiringNotification.php @@ -100,4 +100,16 @@ class ApiTokenExpiringNotification extends CustomEmailNotification color: SlackMessage::warningColor(), ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => "API token '{$this->tokenName}' expires on {$this->expiresAt}. Rotate this token before it expires to avoid API outages.", + 'event' => 'api_token_expiring', + 'token_name' => $this->tokenName, + 'expires_at' => $this->expiresAt, + 'url' => $this->manageUrl, + ]; + } } diff --git a/app/Notifications/Internal/GeneralNotification.php b/app/Notifications/Internal/GeneralNotification.php index 1d23672100..52e986ed6e 100644 --- a/app/Notifications/Internal/GeneralNotification.php +++ b/app/Notifications/Internal/GeneralNotification.php @@ -58,4 +58,14 @@ class GeneralNotification extends Notification implements ShouldQueue color: SlackMessage::infoColor(), ); } + + public function toWebhook(): array + { + return [ + 'success' => true, + 'message' => $this->message, + 'event' => 'general', + 'url' => base_url(), + ]; + } } diff --git a/app/Notifications/Server/ForceDisabled.php b/app/Notifications/Server/ForceDisabled.php index 4b56f5860b..2d2ebabaf0 100644 --- a/app/Notifications/Server/ForceDisabled.php +++ b/app/Notifications/Server/ForceDisabled.php @@ -74,4 +74,16 @@ class ForceDisabled extends CustomEmailNotification color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => "Server ({$this->server->name}) disabled because it is not paid! All automations and integrations are stopped.", + 'event' => 'server_force_disabled', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'url' => base_url().'/server/'.$this->server->uuid, + ]; + } } diff --git a/app/Notifications/Server/ForceEnabled.php b/app/Notifications/Server/ForceEnabled.php index 36dad3c60f..61022d36b5 100644 --- a/app/Notifications/Server/ForceEnabled.php +++ b/app/Notifications/Server/ForceEnabled.php @@ -65,4 +65,16 @@ class ForceEnabled extends CustomEmailNotification color: SlackMessage::successColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => true, + 'message' => "Server ({$this->server->name}) enabled again!", + 'event' => 'server_force_enabled', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'url' => base_url().'/server/'.$this->server->uuid, + ]; + } } diff --git a/app/Notifications/SslExpirationNotification.php b/app/Notifications/SslExpirationNotification.php index 78e1e8be9c..73c7a665d3 100644 --- a/app/Notifications/SslExpirationNotification.php +++ b/app/Notifications/SslExpirationNotification.php @@ -148,4 +148,18 @@ class SslExpirationNotification extends CustomEmailNotification color: SlackMessage::warningColor() ); } + + public function toWebhook(): array + { + $resourceNames = $this->resources->pluck('name'); + + return [ + 'success' => false, + 'message' => "SSL certificates have been renewed for: {$resourceNames->join(', ')}. These resources need to be redeployed manually for the new SSL certificates to take effect.", + 'event' => 'ssl_certificate_renewal', + 'resources' => $resourceNames->values()->all(), + 'urls' => $this->urls, + 'url' => base_url(), + ]; + } } diff --git a/tests/Unit/Notifications/Channels/WebhookChannelTest.php b/tests/Unit/Notifications/Channels/WebhookChannelTest.php new file mode 100644 index 0000000000..783159e69e --- /dev/null +++ b/tests/Unit/Notifications/Channels/WebhookChannelTest.php @@ -0,0 +1,113 @@ + InstanceSettings::query()->updateOrCreate(['id' => 0], [ + 'fqdn' => 'https://coolify.example.com', + ])); + Queue::fake(); + + $this->team = Team::create([ + 'name' => 'Webhook Channel Team', + 'personal_team' => false, + 'show_boarding' => false, + ]); + // Assign through the model so the `encrypted` cast on webhook_url is applied. + $settings = $this->team->webhookNotificationSettings; + $settings->webhook_enabled = true; + $settings->webhook_url = 'https://webhook.example.com/coolify'; + $settings->save(); + $this->team->refresh(); +}); + +/** + * Send a notification through the webhook channel and return the dispatched payload. + * + * @return array + */ +function deliverOverWebhook(Team $team, Notification $notification): array +{ + expect($notification->via($team))->toContain(WebhookChannel::class); + + (new WebhookChannel)->send($team, $notification); + + $payload = null; + Queue::assertPushed(SendWebhookJob::class, function (SendWebhookJob $job) use (&$payload) { + $payload = $job->payload; + + return true; + }); + + expect($payload)->toBeArray() + ->and($payload['success'])->toBeBool() + ->and($payload['message'])->toBeString()->not->toBeEmpty(); + + return $payload; +} + +it('delivers ssl certificate renewal notifications over the webhook channel', function () { + $payload = deliverOverWebhook( + $this->team, + new SslExpirationNotification([(object) ['name' => 'my-application']]) + ); + + expect($payload['event'])->toBe('ssl_certificate_renewal') + ->and($payload['resources'])->toBe(['my-application']); +}); + +it('delivers api token expiring notifications over the webhook channel', function () { + $token = new PersonalAccessToken([ + 'name' => 'ci-token', + 'expires_at' => now()->addDay(), + ]); + + $payload = deliverOverWebhook($this->team, new ApiTokenExpiringNotification($token)); + + expect($payload['event'])->toBe('api_token_expiring') + ->and($payload['token_name'])->toBe('ci-token'); +}); + +it('delivers server force enabled notifications over the webhook channel', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + + $payload = deliverOverWebhook($this->team, new ForceEnabled($server)); + + expect($payload['event'])->toBe('server_force_enabled') + ->and($payload['success'])->toBeTrue() + ->and($payload['server_uuid'])->toBe($server->uuid); +}); + +it('delivers server force disabled notifications over the webhook channel', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + + $payload = deliverOverWebhook($this->team, new ForceDisabled($server)); + + expect($payload['event'])->toBe('server_force_disabled') + ->and($payload['success'])->toBeFalse() + ->and($payload['server_uuid'])->toBe($server->uuid); +}); + +it('delivers general notifications over the webhook channel', function () { + $payload = deliverOverWebhook($this->team, new GeneralNotification('Something happened')); + + expect($payload['event'])->toBe('general') + ->and($payload['message'])->toBe('Something happened'); +}); From dba3d114e5ed2be9a466c030c8cbc2ec58b498d9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:09:53 +0200 Subject: [PATCH 03/48] chore(release): bump Coolify version to 4.3.18 --- config/constants.php | 2 +- other/nightly/versions.json | 2 +- tests/Unit/ProductionImageWorkflowTest.php | 4 ++-- versions.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config/constants.php b/config/constants.php index 7ad0c4594f..22d5e10118 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.3.17', + 'version' => env('COOLIFY_VERSION') ?: '4.3.18', 'helper_version' => '1.0.16', 'realtime_version' => '1.0.18', 'railpack_version' => '0.23.0', diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 0e83c0cf74..97ab3b3a6a 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.3.17" + "version": "4.3.18" }, "nightly": { "version": "4.4-rc.1" diff --git a/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php index 140a6276f2..206cf5f34e 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -23,8 +23,8 @@ it('publishes v4 branch builds under the commit sha with a traceable internal ve ->toContain('ARG COOLIFY_VERSION') ->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}') ->and($constants) - ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.17'") - ->and($versions['coolify']['v4']['version'])->toBe('4.3.17') + ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.18'") + ->and($versions['coolify']['v4']['version'])->toBe('4.3.18') ->and($versions['coolify']['nightly']['version'])->toBe('4.4-rc.1') ->and($nightlyVersions)->toBe($versions); }); diff --git a/versions.json b/versions.json index 0e83c0cf74..97ab3b3a6a 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.3.17" + "version": "4.3.18" }, "nightly": { "version": "4.4-rc.1" From 9c3fb1da396bdd26ece4711f673c46540287039f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:56:08 +0200 Subject: [PATCH 04/48] fix(domains): limit instance addresses to localhost DNS hints --- .../InteractsWithCloudflareDomainConnect.php | 27 +++++---- app/Livewire/Project/Application/Domains.php | 5 ++ app/Livewire/Project/Service/Domains.php | 5 ++ tests/Feature/ApplicationDomainsTest.php | 57 +++++++++++++++++++ tests/Feature/ServiceDomainsTest.php | 18 ++++++ 5 files changed, 100 insertions(+), 12 deletions(-) diff --git a/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php b/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php index 44dba0d5e8..6ecea8e96f 100644 --- a/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php +++ b/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php @@ -209,19 +209,20 @@ trait InteractsWithCloudflareDomainConnect } } - // Prefer instance public IPv6 when the destination IP is IPv4-only (and vice versa). - try { - $settings = instanceSettings(); - $publicV4 = data_get($settings, 'public_ipv4'); - $publicV6 = data_get($settings, 'public_ipv6'); - if ($ipv4 === null && is_string($publicV4) && filter_var($publicV4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - $ipv4 = $publicV4; + if ($this->usesInstanceNetworkAddressesForDnsHints()) { + try { + $settings = instanceSettings(); + $publicV4 = data_get($settings, 'public_ipv4'); + $publicV6 = data_get($settings, 'public_ipv6'); + if ($ipv4 === null && is_string($publicV4) && filter_var($publicV4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $ipv4 = $publicV4; + } + if ($ipv6 === null && is_string($publicV6) && filter_var($publicV6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $ipv6 = $publicV6; + } + } catch (\Throwable) { + // } - if ($ipv6 === null && is_string($publicV6) && filter_var($publicV6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { - $ipv6 = $publicV6; - } - } catch (\Throwable) { - // } return [$ipv4, $ipv6]; @@ -253,5 +254,7 @@ trait InteractsWithCloudflareDomainConnect return null; } + abstract protected function usesInstanceNetworkAddressesForDnsHints(): bool; + abstract protected function authorizeUpdateForDomainConnect(): void; } diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index d7797935f5..839c61e36a 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -661,6 +661,11 @@ class Domains extends Component $this->authorize('update', $this->application); } + protected function usesInstanceNetworkAddressesForDnsHints(): bool + { + return $this->application->destination?->server?->id === 0; + } + public function checkAllDns(): void { $this->authorize('update', $this->application); diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index d5254e093a..4ac2f40b2b 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -459,6 +459,11 @@ class Domains extends Component $this->authorize('update', $this->service); } + protected function usesInstanceNetworkAddressesForDnsHints(): bool + { + return $this->service->server?->id === 0; + } + public function checkAllDns(): void { $this->authorize('update', $this->service); diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index f2beb22f13..396cccbfc9 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -716,6 +716,63 @@ it('lists dns entries for domains that still need dns and omits working configur ->not->toContain('app.example.com'); }); +it('does not use instance network addresses for dns entries on a remote server', function () { + InstanceSettings::get()->update([ + 'public_ipv4' => '198.51.100.20', + 'public_ipv6' => '2001:db8::20', + ]); + $this->application->update(['fqdn' => 'https://app.example.com']); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]); + $records = $component->instance()->dnsRecordHints(); + + expect($records)->toBe([ + [ + 'type' => 'A', + 'name' => 'app.example.com', + 'value' => '203.0.113.10', + ], + ]); +}); + +it('uses instance network addresses for dns entries on the localhost server', function () { + InstanceSettings::get()->update([ + 'public_ipv4' => '198.51.100.20', + 'public_ipv6' => '2001:db8::20', + ]); + $localhost = Server::factory()->create([ + 'id' => 0, + 'team_id' => $this->team->id, + 'private_key_id' => $this->server->private_key_id, + 'ip' => 'localhost', + ]); + $destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::forceCreate([ + 'uuid' => (string) Str::uuid(), + 'name' => 'localhost-docker', + 'network' => 'coolify-localhost', + 'server_id' => $localhost->id, + ])); + $this->application->update([ + 'destination_id' => $destination->id, + 'fqdn' => 'https://app.example.com', + ]); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]); + + expect($component->instance()->dnsRecordHints())->toBe([ + [ + 'type' => 'A', + 'name' => 'app.example.com', + 'value' => '198.51.100.20', + ], + [ + 'type' => 'AAAA', + 'name' => 'app.example.com', + 'value' => '2001:db8::20', + ], + ]); +}); + it('shows cloudflare domain connect only on cloud with a key', function () { config([ 'constants.coolify.self_hosted' => false, diff --git a/tests/Feature/ServiceDomainsTest.php b/tests/Feature/ServiceDomainsTest.php index 4f825f8e24..c93a4f1e9d 100644 --- a/tests/Feature/ServiceDomainsTest.php +++ b/tests/Feature/ServiceDomainsTest.php @@ -280,6 +280,24 @@ it('lists dns entries for service hosts that still need dns', function () { ->not->toContain('web.example.com'); }); +it('does not use instance network addresses for service dns entries on a remote server', function () { + InstanceSettings::get()->update([ + 'public_ipv4' => '198.51.100.20', + 'public_ipv6' => '2001:db8::20', + ]); + $this->apiApp->update(['fqdn' => 'https://api.example.com']); + + $component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]); + + expect($component->instance()->dnsRecordHints())->toBe([ + [ + 'type' => 'A', + 'name' => 'api.example.com', + 'value' => '203.0.113.10', + ], + ]); +}); + it('persists a service redirect when its dropdown changes', function () { $this->webApp->update(['fqdn' => 'https://web.example.com', 'redirect' => 'both']); From 3c0e43f482317dd2d5b0db99fc272b563b8c9c9b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:15:06 +0200 Subject: [PATCH 05/48] feat(settings): configure CDN URL for stored images Add a persisted instance setting for S3 image CDN URLs and use it when building image links. Cache profile avatars and project icons with immutable one-year headers. --- .env.development.example | 1 - .env.windows-docker-desktop.example | 1 - .../Controllers/ProfileAvatarController.php | 2 +- .../Controllers/ProjectIconController.php | 5 +++- app/Livewire/Settings/Advanced.php | 5 ++++ app/Models/InstanceSettings.php | 1 + bootstrap/helpers/shared.php | 2 +- config/constants.php | 1 - ...age_cdn_url_to_instance_settings_table.php | 28 +++++++++++++++++++ .../livewire/settings/advanced.blade.php | 6 ++-- tests/Feature/ProfileAvatarTest.php | 9 +++--- tests/Feature/ProjectIconTest.php | 9 +++--- tests/Feature/SettingsAccessListboxTest.php | 28 +++++++++++++++++++ 13 files changed, 82 insertions(+), 16 deletions(-) create mode 100644 database/migrations/2026_09_04_191011_add_image_cdn_url_to_instance_settings_table.php diff --git a/.env.development.example b/.env.development.example index 56c17128ce..380f10a446 100644 --- a/.env.development.example +++ b/.env.development.example @@ -53,4 +53,3 @@ DUSK_DRIVER_URL=http://selenium:4444 BUNNY_API_KEY= # For asset uploads BUNNY_STORAGE_API_KEY= -AVATAR_CDN_URL= diff --git a/.env.windows-docker-desktop.example b/.env.windows-docker-desktop.example index 626d76ff63..b067b4c5c0 100644 --- a/.env.windows-docker-desktop.example +++ b/.env.windows-docker-desktop.example @@ -11,4 +11,3 @@ REDIS_PASSWORD=coolify PUSHER_APP_ID=coolify PUSHER_APP_KEY=coolify PUSHER_APP_SECRET=coolify -AVATAR_CDN_URL= diff --git a/app/Http/Controllers/ProfileAvatarController.php b/app/Http/Controllers/ProfileAvatarController.php index 2cf01400e8..f53cef7c51 100644 --- a/app/Http/Controllers/ProfileAvatarController.php +++ b/app/Http/Controllers/ProfileAvatarController.php @@ -14,7 +14,7 @@ class ProfileAvatarController extends Controller return response($contents, 200, [ 'Content-Type' => 'image/jpeg', - 'Cache-Control' => 'private, max-age=300', + 'Cache-Control' => 'private, max-age=31536000, immutable', ]); } } diff --git a/app/Http/Controllers/ProjectIconController.php b/app/Http/Controllers/ProjectIconController.php index fb7ebc8860..d99e9166d0 100644 --- a/app/Http/Controllers/ProjectIconController.php +++ b/app/Http/Controllers/ProjectIconController.php @@ -15,6 +15,9 @@ class ProjectIconController extends Controller abort_if($contents === null, 404); - return response($contents)->header('Content-Type', 'image/jpeg'); + return response($contents, 200, [ + 'Content-Type' => 'image/jpeg', + 'Cache-Control' => 'private, max-age=31536000, immutable', + ]); } } diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index fd5ee616d9..45aff3f3c9 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -53,6 +53,8 @@ class Advanced extends Component public string $avatar_storage = 'local'; + public ?string $image_cdn_url = null; + public array $avatar_storage_options = []; public function rules() @@ -71,6 +73,7 @@ class Advanced extends Component 'webhook_allowed_internal_hosts' => 'nullable|string', 'webhook_allow_localhost' => 'boolean', 'domain_connect_private_key' => 'nullable|string', + 'image_cdn_url' => 'nullable|url|max:255', ]; } @@ -97,6 +100,7 @@ class Advanced extends Component $this->avatar_storage = $this->settings->avatar_storage_type === 's3' && $this->settings->avatar_s3_storage_id ? 's3:'.$this->settings->avatar_s3_storage_id : 'local'; + $this->image_cdn_url = $this->settings->image_cdn_url; $this->avatar_storage_options = [ ['value' => 'local', 'label' => 'Local storage'], ...S3Storage::query() @@ -210,6 +214,7 @@ class Advanced extends Component $this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled; $this->settings->webhook_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? []; $this->settings->webhook_allow_localhost = $this->webhook_allow_localhost; + $this->settings->image_cdn_url = filled($this->image_cdn_url) ? rtrim($this->image_cdn_url, '/') : null; $this->saveAvatarStorageSetting(); $this->settings->save(); $this->dispatch('success', 'Settings updated!'); diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index eb01fa7ada..26aceec354 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -56,6 +56,7 @@ class InstanceSettings extends Model 'webhook_allow_localhost', 'avatar_storage_type', 'avatar_s3_storage_id', + 'image_cdn_url', 'is_dashboard_force_https_enabled', ]; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index f9bf44dffa..bc5af1e86e 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -860,7 +860,7 @@ function s3_image_url(?int $storageId, ?string $path, int $version): ?string return null; } - $baseUrl = config('constants.coolify.avatar_cdn_url') ?: $storage->awsUrl(); + $baseUrl = instanceSettings()->image_cdn_url ?: $storage->awsUrl(); return rtrim($baseUrl, '/').'/'.ltrim($path, '/').'?v='.$version; } diff --git a/config/constants.php b/config/constants.php index 22d5e10118..bdcd5d7c9d 100644 --- a/config/constants.php +++ b/config/constants.php @@ -14,7 +14,6 @@ return [ 'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'docker.io').'/coollabsio/coolify-realtime'), 'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false), 'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'), - 'avatar_cdn_url' => env('AVATAR_CDN_URL'), 'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'), 'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'), 'releases_url' => env('RELEASES_URL', 'https://cdn.coollabs.io/coolify/releases.json'), diff --git a/database/migrations/2026_09_04_191011_add_image_cdn_url_to_instance_settings_table.php b/database/migrations/2026_09_04_191011_add_image_cdn_url_to_instance_settings_table.php new file mode 100644 index 0000000000..1c898dd5da --- /dev/null +++ b/database/migrations/2026_09_04_191011_add_image_cdn_url_to_instance_settings_table.php @@ -0,0 +1,28 @@ +string('image_cdn_url')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('image_cdn_url'); + }); + } +}; diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index 94927ba257..4c11cb41d4 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -9,7 +9,7 @@ listboxes (API, MCP, telemetry, …) update the snapshot on the server immediately; without wire:target they briefly flash this bar. --}} + targets="custom_dns_servers,allowed_ips,webhook_allowed_internal_hosts,webhook_allow_localhost,domain_connect_private_key,image_cdn_url" />
@@ -144,9 +144,11 @@ -
+
+
@if (count($avatar_storage_options) === 1) diff --git a/tests/Feature/ProfileAvatarTest.php b/tests/Feature/ProfileAvatarTest.php index 68ced553d5..3512fccea3 100644 --- a/tests/Feature/ProfileAvatarTest.php +++ b/tests/Feature/ProfileAvatarTest.php @@ -72,11 +72,14 @@ it('serves the authenticated users profile picture', function () { $this->withoutMiddleware()->actingAs($user) ->get(route('profile.avatar')) ->assertSuccessful() - ->assertHeader('content-type', 'image/jpeg'); + ->assertHeader('content-type', 'image/jpeg') + ->assertHeader('cache-control', 'immutable, max-age=31536000, private'); }); it('loads an S3 profile picture from the configured CDN', function () { - config()->set('constants.coolify.avatar_cdn_url', 'https://avatars.example.com/media/'); + InstanceSettings::findOrFail(0)->update([ + 'image_cdn_url' => 'https://avatars.example.com/media', + ]); Team::factory()->create(['id' => 0]); $storage = S3Storage::query()->create([ 'team_id' => 0, @@ -98,7 +101,6 @@ it('loads an S3 profile picture from the configured CDN', function () { }); it('loads an S3 profile picture directly from S3 when the CDN is not configured', function () { - config()->set('constants.coolify.avatar_cdn_url'); Team::factory()->create(['id' => 0]); $storage = S3Storage::query()->create([ 'team_id' => 0, @@ -120,7 +122,6 @@ it('loads an S3 profile picture directly from S3 when the CDN is not configured' }); it('does not use an unrelated S3 storage URL for a profile picture', function () { - config()->set('constants.coolify.avatar_cdn_url', 'https://avatars.example.com'); $storage = S3Storage::query()->create([ 'team_id' => Team::factory()->create()->id, 'name' => 'Unrelated storage', diff --git a/tests/Feature/ProjectIconTest.php b/tests/Feature/ProjectIconTest.php index fc03c9f30e..99a6c0ac5b 100644 --- a/tests/Feature/ProjectIconTest.php +++ b/tests/Feature/ProjectIconTest.php @@ -61,7 +61,8 @@ it('serves a project icon only to a member of its team', function () { $this->withoutMiddleware()->get(route('project.icon', ['project_uuid' => $this->project->uuid])) ->assertSuccessful() - ->assertHeader('content-type', 'image/jpeg'); + ->assertHeader('content-type', 'image/jpeg') + ->assertHeader('cache-control', 'immutable, max-age=31536000, private'); $otherUser = User::factory()->create(); $otherTeam = Team::factory()->create(); @@ -104,7 +105,9 @@ it('exposes the icon URL on the projects index', function () { }); it('loads an S3 project icon from the configured CDN', function () { - config()->set('constants.coolify.avatar_cdn_url', 'https://avatars.example.com/media/'); + InstanceSettings::findOrFail(0)->update([ + 'image_cdn_url' => 'https://avatars.example.com/media', + ]); Team::factory()->create(['id' => 0]); $storage = S3Storage::query()->create([ 'team_id' => 0, @@ -127,7 +130,6 @@ it('loads an S3 project icon from the configured CDN', function () { }); it('loads an S3 project icon directly from S3 when the CDN is not configured', function () { - config()->set('constants.coolify.avatar_cdn_url'); Team::factory()->create(['id' => 0]); $storage = S3Storage::query()->create([ 'team_id' => 0, @@ -149,7 +151,6 @@ it('loads an S3 project icon directly from S3 when the CDN is not configured', f }); it('does not use an unusable S3 storage URL for a project icon', function () { - config()->set('constants.coolify.avatar_cdn_url', 'https://avatars.example.com'); Team::factory()->create(['id' => 0]); $storage = S3Storage::query()->create([ 'team_id' => 0, diff --git a/tests/Feature/SettingsAccessListboxTest.php b/tests/Feature/SettingsAccessListboxTest.php index 4ec9dceb9e..c1508bf73b 100644 --- a/tests/Feature/SettingsAccessListboxTest.php +++ b/tests/Feature/SettingsAccessListboxTest.php @@ -29,6 +29,12 @@ test('settings advanced access section always uses listboxes', function () { ->not->toContain('Two-step confirmations enabled'); }); +test('image storage fields use the standard settings field gap', function () { + $contents = file_get_contents(resource_path('views/livewire/settings/advanced.blade.php')); + + expect($contents)->toContain('
'); +}); + test('instance admin can toggle registration via listbox instantSave', function () { $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]); @@ -82,6 +88,28 @@ test('instance admin can toggle two-step confirmation via listbox instantSave', expect((bool) $settings->fresh()->disable_two_step_confirmation)->toBeTrue(); }); +test('instance admin can configure the image CDN URL at runtime', function () { + $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]); + $settings = InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); + + $user = User::factory()->create(); + $rootTeam->members()->attach($user->id, ['role' => 'admin']); + + $this->actingAs($user); + session(['currentTeam' => ['id' => $rootTeam->id]]); + + Livewire::test(Advanced::class) + ->assertSee('Image CDN URL') + ->set('image_cdn_url', 'https://images.example.com/media/') + ->call('submit') + ->assertHasNoErrors() + ->assertDispatched('success'); + + expect($settings->fresh()->image_cdn_url)->toBe('https://images.example.com/media'); +}); + test('open API allowlist warning is hidden when API access is disabled', function () { $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]); From 27c7dc6a243d5362419e88e60e919829682054de Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:29:14 +0200 Subject: [PATCH 06/48] chore(notifications): remove broken notification interface --- app/Notifications/Notification.php | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 app/Notifications/Notification.php diff --git a/app/Notifications/Notification.php b/app/Notifications/Notification.php deleted file mode 100644 index d37716a8b4..0000000000 --- a/app/Notifications/Notification.php +++ /dev/null @@ -1,22 +0,0 @@ - Date: Sat, 5 Sep 2026 12:32:25 +0200 Subject: [PATCH 07/48] fix(notifications): hetzner deletion failure channel name --- app/Notifications/Server/HetznerDeletionFailed.php | 3 +-- tests/Unit/HetznerDeletionFailedNotificationTest.php | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/Notifications/Server/HetznerDeletionFailed.php b/app/Notifications/Server/HetznerDeletionFailed.php index bb452b054b..a109a4360d 100644 --- a/app/Notifications/Server/HetznerDeletionFailed.php +++ b/app/Notifications/Server/HetznerDeletionFailed.php @@ -17,8 +17,7 @@ class HetznerDeletionFailed extends CustomEmailNotification public function via(object $notifiable): array { - - return $notifiable->getEnabledChannels('hetzner_deletion_failed'); + return $notifiable->getEnabledChannels('hetzner_deletion_failure'); } public function toMail(): MailMessage diff --git a/tests/Unit/HetznerDeletionFailedNotificationTest.php b/tests/Unit/HetznerDeletionFailedNotificationTest.php index 22d5e80db3..bcd3f294ee 100644 --- a/tests/Unit/HetznerDeletionFailedNotificationTest.php +++ b/tests/Unit/HetznerDeletionFailedNotificationTest.php @@ -19,7 +19,7 @@ it('can be instantiated with correct properties', function () { ->and($notification->errorMessage)->toBe('Hetzner API error: Server not found'); }); -it('uses hetzner_deletion_failed event for channels', function () { +it('uses the always-send hetzner_deletion_failure event for channels', function () { $notification = new HetznerDeletionFailed( hetznerServerId: 12345, teamId: 1, @@ -28,7 +28,7 @@ it('uses hetzner_deletion_failed event for channels', function () { $mockNotifiable = Mockery::mock(); $mockNotifiable->shouldReceive('getEnabledChannels') - ->with('hetzner_deletion_failed') + ->with('hetzner_deletion_failure') ->once() ->andReturn([]); From 9f6ed823ce5d1639ccc7ea5b254c0f6a7a95b877 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:33:06 +0200 Subject: [PATCH 08/48] fix(notifications): add toWebhook payload to Hetzner deletion failure notification --- app/Notifications/Server/HetznerDeletionFailed.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/Notifications/Server/HetznerDeletionFailed.php b/app/Notifications/Server/HetznerDeletionFailed.php index a109a4360d..6c2712b68d 100644 --- a/app/Notifications/Server/HetznerDeletionFailed.php +++ b/app/Notifications/Server/HetznerDeletionFailed.php @@ -65,4 +65,16 @@ class HetznerDeletionFailed extends CustomEmailNotification color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => "[ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud. The server has been removed from Coolify, but may still exist in your Hetzner Cloud account.", + 'event' => 'hetzner_deletion_failed', + 'hetzner_server_id' => $this->hetznerServerId, + 'error' => $this->errorMessage, + 'url' => base_url().'/servers', + ]; + } } From fb393da352afd6c0ca41a9e6795f955b37a1db06 Mon Sep 17 00:00:00 2001 From: Bo Sundgaard <46848138+bosund@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:00:04 +0200 Subject: [PATCH 09/48] fix(docker): raise nginx request header buffers above the 8k default (#11404) Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> --- docker/development/etc/nginx/conf.d/custom.conf | 6 ++++++ docker/production/etc/nginx/conf.d/custom.conf | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/docker/development/etc/nginx/conf.d/custom.conf b/docker/development/etc/nginx/conf.d/custom.conf index f26dc30495..4672e3de55 100644 --- a/docker/development/etc/nginx/conf.d/custom.conf +++ b/docker/development/etc/nginx/conf.d/custom.conf @@ -2,3 +2,9 @@ # Disable access logs access_log off; + +# Allow request headers up to 32k (nginx default is 8k). Large JWT cookies can push the +# Cookie header past 8k, and nginx would then reject the request with a bare 400 +# before it reaches the application. +client_header_buffer_size 8k; +large_client_header_buffers 8 32k; diff --git a/docker/production/etc/nginx/conf.d/custom.conf b/docker/production/etc/nginx/conf.d/custom.conf index f26dc30495..4672e3de55 100644 --- a/docker/production/etc/nginx/conf.d/custom.conf +++ b/docker/production/etc/nginx/conf.d/custom.conf @@ -2,3 +2,9 @@ # Disable access logs access_log off; + +# Allow request headers up to 32k (nginx default is 8k). Large JWT cookies can push the +# Cookie header past 8k, and nginx would then reject the request with a bare 400 +# before it reaches the application. +client_header_buffer_size 8k; +large_client_header_buffers 8 32k; From 94bf7910ad5c2bbb12fdebee42bdf7f90c3f9147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=8F=94=EF=B8=8F=20Peak?= <122374094+peaklabs-dev@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:00:58 +0200 Subject: [PATCH 10/48] fix(service): persist service database public access (#11633) --- app/Livewire/Project/Service/Index.php | 36 +++++++++++++++++--------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index 7980e07056..3b8ca7af19 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -307,36 +307,48 @@ class Index extends Component public function instantSave() { + $this->authorize('update', $this->serviceDatabase); try { - $this->authorize('update', $this->serviceDatabase); - if ($this->isPublic && ! $this->publicPort) { - $this->dispatch('error', 'Public port is required.'); - $this->isPublic = false; - - return; - } - $this->syncDatabaseData(true); - if ($this->serviceDatabase->is_public) { - if (! str($this->serviceDatabase->status)->startsWith('running')) { - $this->dispatch('error', 'Database must be started to be publicly accessible.'); + if ($this->isPublic) { + if (! $this->publicPort) { + $this->dispatch('error', 'Public port is required.'); $this->isPublic = false; - $this->serviceDatabase->is_public = false; return; } + if (! str($this->serviceDatabase->status)->startsWith('running')) { + $this->dispatch('error', 'Database must be started to be publicly accessible.'); + $this->isPublic = false; + + return; + } + $this->persistPublicAccess(); StartDatabaseProxy::run($this->serviceDatabase); $this->db_url_public = $this->serviceDatabase->getServiceDatabaseUrl(); $this->dispatch('success', 'Database is now publicly accessible.'); } else { + $this->persistPublicAccess(); StopDatabaseProxy::run($this->serviceDatabase); $this->db_url_public = null; $this->dispatch('success', 'Database is no longer publicly accessible.'); } } catch (\Throwable $e) { + $this->isPublic = ! $this->isPublic; + $this->persistPublicAccess(); + return handleError($e, $this); } } + private function persistPublicAccess(): void + { + $this->serviceDatabase->update([ + 'is_public' => $this->isPublic, + 'public_port' => $this->publicPort ?: null, + 'public_port_timeout' => $this->publicPortTimeout ?: null, + ]); + } + public function submitDatabase() { try { From 16295e1ab33bc8f7012d1daa2b58416ca55e1ed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Devrim=20Tun=C3=A7er?= <151394142+devrim-1283@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:10:21 +0300 Subject: [PATCH 11/48] fix(ui): keep modal content across re-renders (#11294) --- resources/views/components/modal-input.blade.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/resources/views/components/modal-input.blade.php b/resources/views/components/modal-input.blade.php index 588032e039..144a1a9f9a 100644 --- a/resources/views/components/modal-input.blade.php +++ b/resources/views/components/modal-input.blade.php @@ -15,10 +15,6 @@ 'isLarge' => false, ]) -@php - $modalId = 'modal-' . uniqid(); -@endphp -
-
Date: Sat, 5 Sep 2026 14:19:38 +0200 Subject: [PATCH 12/48] fix: avoid inherited compose ports and defer archive inspection Prevent multi-service Compose domains from inheriting the application port, and defer PostgreSQL custom-format archive inspection to pg_restore. --- app/Livewire/Project/Application/Domains.php | 7 ++ .../Project/Application/PreviewDomains.php | 7 ++ app/Support/DatabaseBackupFileValidator.php | 9 +-- bootstrap/helpers/parsers.php | 6 +- bootstrap/helpers/shared.php | 8 ++- tests/Feature/ApplicationDomainsTest.php | 65 +++++++++++++++++++ ...licationParserDockerComposeDomainsTest.php | 34 ++++++++++ .../DatabaseBackupUploadValidationTest.php | 8 +-- 8 files changed, 130 insertions(+), 14 deletions(-) diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 839c61e36a..62153d1757 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -569,6 +569,13 @@ class Domains extends Component ]; } + if ($this->isCompose && $service !== null && count($this->composeServices) > 1) { + return [ + 'internal_port' => null, + 'has_port_override' => false, + ]; + } + $exposed = $this->application->ports_exposes_array; $defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0 ? (int) $exposed[0] diff --git a/app/Livewire/Project/Application/PreviewDomains.php b/app/Livewire/Project/Application/PreviewDomains.php index e6e9f5d60f..66eb2f5e01 100644 --- a/app/Livewire/Project/Application/PreviewDomains.php +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -537,6 +537,13 @@ class PreviewDomains extends Component ]; } + if ($this->preview->application->build_pack === 'dockercompose' && $service !== null && count($this->composeServices()) > 1) { + return [ + 'internal_port' => null, + 'has_port_override' => false, + ]; + } + $exposed = $this->preview->application->ports_exposes_array; $defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0 ? (int) $exposed[0] diff --git a/app/Support/DatabaseBackupFileValidator.php b/app/Support/DatabaseBackupFileValidator.php index 84e629fe1a..2c1de948ba 100644 --- a/app/Support/DatabaseBackupFileValidator.php +++ b/app/Support/DatabaseBackupFileValidator.php @@ -90,11 +90,8 @@ class DatabaseBackupFileValidator public static function containsPostgresqlProgramExecution(string $sql): bool { - $requireStatementBoundary = true; - if (str_starts_with($sql, 'PGDMP')) { - $sql = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]+/', "\n", $sql) ?? $sql; - $requireStatementBoundary = false; + return false; } $withoutComments = self::stripSqlComments($sql); @@ -103,9 +100,7 @@ class DatabaseBackupFileValidator return true; } - $copyPrefix = $requireStatementBoundary ? '(?:^|;)\s*' : '\b'; - - return preg_match('/'.$copyPrefix.'copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; + return preg_match('/(?:^|;)\s*copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; } private static function extensionFor(string $name): ?string diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 03ebf5af7a..dd8ea29c88 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -390,6 +390,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int return collect([]); } $services = data_get($yaml, 'services', collect([])); + $applicationServiceCount = collect($services) + ->reject(fn (mixed $service): bool => isDatabaseImage(data_get($service, 'image'))) + ->count(); $topLevel = collect([ 'volumes' => collect(data_get($yaml, 'volumes', [])), 'networks' => collect(data_get($yaml, 'networks', [])), @@ -1355,7 +1358,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int ? ($previewForPorts?->domain_port_overrides ?? []) : ($originalResource->domain_port_overrides ?? []); $exposedPorts = $originalResource->settings->is_static ? [80] : $originalResource->ports_exposes_array; - $onlyPort = firstDockerComposeServicePort($service) ?? ($exposedPorts[0] ?? null); + $onlyPort = firstDockerComposeServicePort($service) + ?? ($applicationServiceCount === 1 ? ($exposedPorts[0] ?? null) : null); if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) { $serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first()); } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index bc5af1e86e..f5db32e886 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3318,7 +3318,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($pull_request_id !== 0) { $definedNetwork = collect(["{$resource->uuid}-$pull_request_id"]); } - $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $server, $pull_request_id, $preview_id) { + $usesSharedApplicationPort = collect($services) + ->reject(fn (mixed $service): bool => isDatabaseImage(data_get($service, 'image'))) + ->count() === 1; + $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $server, $pull_request_id, $preview_id, $usesSharedApplicationPort) { $serviceVolumes = collect(data_get($service, 'volumes', [])); $servicePorts = collect(data_get($service, 'ports', [])); $serviceNetworks = collect(data_get($service, 'networks', [])); @@ -3916,7 +3919,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal ? ($resource->domain_port_overrides ?? []) : ($preview?->domain_port_overrides ?? []); $exposedPorts = $resource->settings->is_static ? [80] : $resource->ports_exposes_array; - $onlyPort = firstDockerComposeServicePort($service) ?? ($exposedPorts[0] ?? null); + $onlyPort = firstDockerComposeServicePort($service) + ?? ($usesSharedApplicationPort ? ($exposedPorts[0] ?? null) : null); if ($shouldGenerateLabelsExactly) { switch ($server->proxyType()) { case ProxyTypes::TRAEFIK->value: diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 396cccbfc9..1cc6d05e13 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -2436,6 +2436,25 @@ it('shows the detected compose service port as the inherited internal port', fun ->assertDontSee('Internal port 3000'); }); +it('does not show an application port as the inherited port for a compose service without a declared port', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'ports_exposes' => '3000', + 'docker_compose_raw' => "services:\n backend:\n build: ./backend\n frontend:\n build: ./frontend\n", + 'docker_compose_domains' => json_encode([ + 'backend' => ['domain' => 'https://api.example.com'], + 'frontend' => ['domain' => 'https://app.example.com'], + ]), + 'fqdn' => null, + 'domain_port_overrides' => null, + ]); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->assertSet('domainRows.0.internal_port', null) + ->assertSet('domainRows.1.internal_port', null) + ->assertDontSee('Internal port 3000'); +}); + it('shows the detected compose service port for preview domains', function () { $this->application->update([ 'build_pack' => 'dockercompose', @@ -2459,6 +2478,27 @@ it('shows the detected compose service port for preview domains', function () { ->assertDontSee('Internal port 3000'); }); +it('does not show an application port for a preview compose service without a declared port', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'ports_exposes' => '3000', + 'docker_compose_raw' => "services:\n backend:\n build: ./backend\n frontend:\n build: ./frontend\n", + ]); + + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 8070, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/8070', + 'docker_compose_domains' => json_encode([ + 'frontend' => ['domain' => 'https://preview.example.com'], + ]), + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->assertSet('domainRows.0.internal_port', null) + ->assertDontSee('Internal port 3000'); +}); + it('keeps a legacy port-bearing url port in the edit field as an internal port override', function () { $this->application->update([ 'ports_exposes' => '3000,8080', @@ -2514,6 +2554,31 @@ it('stores compose domain port overrides without wiping other services', functio ->toHaveKey('https://api.example.com', 4000); }); +it('saves an unrecognized compose domain port after confirming the warning', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'fqdn' => null, + 'ports_exposes' => '3000', + 'docker_compose_raw' => "services:\n frontend:\n build: ./frontend\n", + 'docker_compose_domains' => json_encode([ + 'frontend' => ['domain' => 'https://app.example.com'], + ]), + 'domain_port_overrides' => null, + ]); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->call('startEdit', 0) + ->set('editingDomainParts.port', '80') + ->call('updateDomain') + ->assertSet('showPortWarningModal', true) + ->call('confirmUseUnknownPort') + ->assertSet('showPortWarningModal', false) + ->assertDispatched('success'); + + expect($this->application->fresh()->domain_port_overrides) + ->toBe(['https://app.example.com' => 80]); +}); + it('prunes a compose domain port override when that domain is removed', function () { $this->application->update([ 'build_pack' => 'dockercompose', diff --git a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php index 30305ef596..c207782022 100644 --- a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php +++ b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php @@ -631,3 +631,37 @@ YAML, 'short port syntax' => " ports:\n - '18069:8069'", 'long port syntax' => " ports:\n - target: 8069\n published: 18069", ]); + +test('applicationParser does not apply an application port to compose services without a declared port', function () { + $application = disableExactProxyLabels(Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockercompose', + 'ports_exposes' => '3000', + 'docker_compose_raw' => <<<'YAML' +services: + postgres: + image: postgres:16-alpine + backend: + build: ./backend + frontend: + build: ./frontend +YAML, + 'fqdn' => null, + 'domain_port_overrides' => null, + 'docker_compose_domains' => json_encode([ + 'backend' => ['domain' => 'https://api.example.com'], + 'frontend' => ['domain' => 'https://app.example.com'], + ]), + ])); + + $services = data_get(applicationParser($application->fresh()), 'services'); + $backendLabels = collect(data_get($services, 'backend.labels')); + $frontendLabels = collect(data_get($services, 'frontend.labels')); + + expect($backendLabels->contains(fn (string $label): bool => str_contains($label, '.loadbalancer.server.port='))) + ->toBeFalse() + ->and($frontendLabels->contains(fn (string $label): bool => str_contains($label, '.loadbalancer.server.port='))) + ->toBeFalse(); +}); diff --git a/tests/Feature/DatabaseBackupUploadValidationTest.php b/tests/Feature/DatabaseBackupUploadValidationTest.php index 88978eeaba..f8416fe6c7 100644 --- a/tests/Feature/DatabaseBackupUploadValidationTest.php +++ b/tests/Feature/DatabaseBackupUploadValidationTest.php @@ -220,16 +220,16 @@ test('file scanner allows ordinary gzipped dumps', function () { expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($gzClean))->toBeFalse(); }); -test('file scanner detects program execution payloads inside custom format archives', function () { +test('file scanner defers custom format archives to pg_restore inspection', function () { $archive = writeScanPayload("PGDMP\0binary COPY records FROM PROGRAM payload"); - expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeTrue(); + expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeFalse(); }); -test('file scanner detects program execution payloads inside gzipped custom format archives', function () { +test('file scanner defers gzipped custom format archives to pg_restore inspection', function () { $archive = writeScanPayload("PGDMP\0binary COPY records FROM PROGRAM payload", gzip: true); - expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeTrue(); + expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeFalse(); }); test('file scanner allows custom format archives without program execution', function () { From 47c61feb8470e353f953baceca774735bae86b6d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:24:24 +0200 Subject: [PATCH 13/48] fix(api): persist Docker Compose domain ports as overrides Normalize Compose domains on create and update, retaining explicit ports in `domain_port_overrides` while storing port-free domain values. Preserve empty Compose FQDNs and cover both API flows with feature tests. --- .../Api/ApplicationsController.php | 36 ++++++++++++ app/Models/Application.php | 9 ++- .../Api/ApplicationSettingsApiTest.php | 55 +++++++++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 487d319e18..dbe0f86336 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -27,6 +27,7 @@ use App\Support\DomainPortOverrides; use App\Support\ValidationPatterns; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; @@ -1455,7 +1456,9 @@ class ApplicationsController extends Controller $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { + [$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson); $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); + $application->domain_port_overrides = $domainPortOverrides; } $repository_url_parsed = Url::fromString($request->git_repository); $git_host = $repository_url_parsed->getHost(); @@ -1719,7 +1722,9 @@ class ApplicationsController extends Controller $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { + [$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson); $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); + $application->domain_port_overrides = $domainPortOverrides; } $application->fqdn = $fqdn; $application->git_repository = str($gitRepository)->trim()->toString(); @@ -1950,7 +1955,9 @@ class ApplicationsController extends Controller $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { + [$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson); $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); + $application->domain_port_overrides = $domainPortOverrides; } $application->fqdn = $fqdn; $application->private_key_id = $privateKey->id; @@ -3369,7 +3376,12 @@ class ApplicationsController extends Controller } if ($dockerComposeDomainsJson->count() > 0) { + [$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts( + $dockerComposeDomainsJson, + $application->domain_port_overrides, + ); data_set($data, 'docker_compose_domains', json_encode($dockerComposeDomainsJson)); + data_set($data, 'domain_port_overrides', $domainPortOverrides); } $requestHasNoindexDomains = $request->has('noindex_domains'); data_forget($data, 'noindex_domains'); @@ -6116,4 +6128,28 @@ class ApplicationsController extends Controller return response()->json(['message' => 'Destination detached.']); } + + /** + * @param Collection $domains + * @param array|null $existingOverrides + * @return array{Collection, ?array} + */ + private function normalizeDockerComposeDomainPorts(Collection $domains, ?array $existingOverrides = null): array + { + $allDomains = $domains + ->pluck('domain') + ->filter() + ->implode(','); + $normalized = DomainPortOverrides::normalize($allDomains, $existingOverrides); + + $domains = $domains->map(function (array $entry): array { + $entry['domain'] = collect(ValidationPatterns::applicationDomainList($entry['domain'] ?? null)) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)) + ->implode(','); + + return $entry; + }); + + return [$domains, $normalized['overrides']]; + } } diff --git a/app/Models/Application.php b/app/Models/Application.php index 7f7ce5ea1f..2ac352149f 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\ApplicationDeploymentStatus; +use App\Enums\BuildPackTypes; use App\Services\ConfigurationGenerator; use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot; use App\Services\DeploymentConfiguration\ConfigurationDiff; @@ -291,9 +292,11 @@ class Application extends BaseModel if ($application->fqdn === '') { $application->fqdn = null; } - $normalized = DomainPortOverrides::normalize($application->fqdn, $application->domain_port_overrides); - $application->fqdn = $normalized['fqdn']; - $application->domain_port_overrides = $normalized['overrides']; + if ($application->build_pack !== BuildPackTypes::DOCKERCOMPOSE->value || filled($application->fqdn)) { + $normalized = DomainPortOverrides::normalize($application->fqdn, $application->domain_port_overrides); + $application->fqdn = $normalized['fqdn']; + $application->domain_port_overrides = $normalized['overrides']; + } $payload['fqdn'] = $application->fqdn; $application->syncNoindexDomains(); } diff --git a/tests/Feature/Api/ApplicationSettingsApiTest.php b/tests/Feature/Api/ApplicationSettingsApiTest.php index 55d25927a3..a4c22a2b7c 100644 --- a/tests/Feature/Api/ApplicationSettingsApiTest.php +++ b/tests/Feature/Api/ApplicationSettingsApiTest.php @@ -165,6 +165,61 @@ test('changing a domain port regenerates managed labels with the requested port' ->and($labels)->not->toContain('loadbalancer.server.port=3000'); }); +test('compose domain ports are stored as overrides when updating through the API', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n api:\n image: nginx\n frontend:\n image: nginx\n", + 'docker_compose_domains' => json_encode([ + 'api' => ['domain' => 'https://api.example.com'], + 'frontend' => ['domain' => 'https://app.example.com'], + ]), + ]); + + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'docker_compose_domains' => [ + ['name' => 'api', 'domain' => 'https://api.example.com'], + ['name' => 'frontend', 'domain' => 'https://app.example.com:80'], + ], + ]) + ->assertOk(); + + $application = $this->application->fresh(); + $domains = json_decode($application->docker_compose_domains, true); + + expect(data_get($domains, 'frontend.domain'))->toBe('https://app.example.com') + ->and($application->domain_port_overrides)->toBe([ + 'https://app.example.com' => 80, + ]); +}); + +test('compose domain ports are stored as overrides when creating through the API', function () { + Queue::fake(); + + $response = $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->postJson('/api/v1/applications/public', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'git_repository' => 'https://gitlab.com/coolify/compose-domain-port-test', + 'git_branch' => 'main', + 'build_pack' => 'dockercompose', + 'autogenerate_domain' => false, + 'docker_compose_domains' => [ + ['name' => 'frontend', 'domain' => 'https://app.example.com:80'], + ], + ]) + ->assertCreated(); + + $application = Application::where('uuid', $response->json('uuid'))->firstOrFail(); + $domains = json_decode($application->docker_compose_domains, true); + + expect(data_get($domains, 'frontend.domain'))->toBe('https://app.example.com') + ->and($application->domain_port_overrides)->toBe([ + 'https://app.example.com' => 80, + ]); +}); + test('http basic auth updates regenerate managed labels', function () { $this->application->settings->update(['is_container_label_readonly_enabled' => true]); $this->application->update([ From 2a25e0490e3672e25a6a6cbe166849debd80edef Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:18:26 +0200 Subject: [PATCH 14/48] fix(storage): persist S3 settings for new volume backups (#11635) --- .../Project/Shared/Storages/VolumeBackups.php | 16 ++- tests/Feature/VolumeBackupTest.php | 112 +++++++++++++++++- 2 files changed, 119 insertions(+), 9 deletions(-) diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index a10eb5ad03..a8e2d72df1 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -147,7 +147,11 @@ class VolumeBackups extends Component } $this->resetErrorBag('s3StorageId'); - $this->backup?->update(['s3_storage_id' => $this->s3StorageId]); + if (! $this->validateSettings()) { + return; + } + + $this->backup = $this->persistBackup($this->enabled); $this->dispatch('success', 'S3 storage updated.'); } @@ -163,11 +167,11 @@ class VolumeBackups extends Component $this->saveToS3 = ! $this->saveToS3; $this->disableLocalBackup = $this->saveToS3 && $this->disableLocalBackup; - $this->backup?->update([ - 'save_s3' => $this->saveToS3, - 'disable_local_backup' => $this->disableLocalBackup, - 's3_storage_id' => $this->s3StorageId, - ]); + if (! $this->validateSettings()) { + return; + } + + $this->backup = $this->persistBackup($this->enabled); $this->dispatch('success', $this->saveToS3 ? 'S3 backups enabled.' : 'S3 backups disabled.'); } diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index b24b45dcad..9c6897632a 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -960,6 +960,67 @@ it('enables and disables volume S3 backups from the S3 title action', function ( expect($backup->refresh()->save_s3)->toBeFalse(); }); +it('persists S3 settings the first time when a volume backup schedule does not exist yet', function () { + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + $s3Storage = S3Storage::create([ + 'name' => 'Volume backups', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + 'is_usable' => true, + ]); + + Livewire::test(VolumeBackups::class, [ + 'storage' => $volume, + 'resource' => $application, + 'section' => 's3', + ]) + ->assertSet('s3StorageId', $s3Storage->id) + ->call('toggleS3') + ->assertSet('saveToS3', true) + ->assertDispatched('success'); + + $backup = ScheduledVolumeBackup::query()->sole(); + + expect($backup->enabled)->toBeFalse() + ->and($backup->save_s3)->toBeTrue() + ->and($backup->s3_storage_id)->toBe($s3Storage->id); +}); + +it('persists the selected S3 storage when a volume backup schedule does not exist yet', function () { + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + $s3Storage = S3Storage::create([ + 'name' => 'Volume backups', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + 'is_usable' => true, + ]); + + Livewire::test(VolumeBackups::class, [ + 'storage' => $volume, + 'resource' => $application, + 'section' => 's3', + ]) + ->set('s3StorageId', $s3Storage->id) + ->assertDispatched('success'); + + $backup = ScheduledVolumeBackup::query()->sole(); + + expect($backup->enabled)->toBeFalse() + ->and($backup->s3_storage_id)->toBe($s3Storage->id); +}); + it('shows and saves volume S3 retention while S3 backups are disabled', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); @@ -1004,7 +1065,7 @@ it('allows team owners to edit volume backup retention settings', function () { ])->assertDontSee('You do not have permission to perform this action.'); }); -it('only updates S3 fields when toggling volume S3 backups', function () { +it('does not enable S3 backups when another volume backup setting is invalid', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); @@ -1031,9 +1092,54 @@ it('only updates S3 fields when toggling volume S3 backups', function () { ]) ->set('frequency', 'not a valid schedule') ->call('toggleS3') - ->assertDispatched('success'); + ->assertHasErrors('frequency') + ->assertNotDispatched('success'); - expect($backup->refresh()->save_s3)->toBeTrue() + expect($backup->refresh()->save_s3)->toBeFalse() + ->and($backup->frequency)->toBe('daily'); +}); + +it('does not change S3 storage when another volume backup setting is invalid', function () { + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + $firstS3Storage = S3Storage::create([ + 'name' => 'First storage', + 'region' => 'us-east-1', + 'key' => 'first-key', + 'secret' => 'secret', + 'bucket' => 'first-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + 'is_usable' => true, + ]); + $secondS3Storage = S3Storage::create([ + 'name' => 'Second storage', + 'region' => 'us-east-1', + 'key' => 'second-key', + 'secret' => 'secret', + 'bucket' => 'second-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + 'is_usable' => true, + ]); + $backup = $volume->scheduledBackups()->create([ + 'team_id' => $team->id, + 'frequency' => 'daily', + 's3_storage_id' => $firstS3Storage->id, + ]); + + Livewire::test(VolumeBackups::class, [ + 'storage' => $volume, + 'resource' => $application, + 'section' => 's3', + ]) + ->set('frequency', 'not a valid schedule') + ->set('s3StorageId', $secondS3Storage->id) + ->assertHasErrors('frequency') + ->assertNotDispatched('success'); + + expect($backup->refresh()->s3_storage_id)->toBe($firstS3Storage->id) ->and($backup->frequency)->toBe('daily'); }); From bf874029d81a6350ca828cd585a5af6ae161a0d2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:18:37 +0200 Subject: [PATCH 15/48] fix(terminal): preserve PATH for SSH proxy commands (#11638) --- config/constants.php | 2 +- docker-compose.prod.yml | 2 +- docker-compose.windows.yml | 2 +- docker/coolify-realtime/terminal-server.js | 3 ++- docker/coolify-realtime/terminal-utils.js | 8 ++++++ .../coolify-realtime/terminal-utils.test.js | 27 +++++++++++++++++++ 6 files changed, 40 insertions(+), 4 deletions(-) diff --git a/config/constants.php b/config/constants.php index bdcd5d7c9d..1e6d40b7b6 100644 --- a/config/constants.php +++ b/config/constants.php @@ -4,7 +4,7 @@ return [ 'coolify' => [ 'version' => env('COOLIFY_VERSION') ?: '4.3.18', 'helper_version' => '1.0.16', - 'realtime_version' => '1.0.18', + 'realtime_version' => '1.0.19', 'railpack_version' => '0.23.0', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ebf12379d5..d611fc69f7 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -62,7 +62,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.18' + image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.19' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index cc266e5562..6a52a0126a 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -97,7 +97,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.18' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.19' pull_policy: always container_name: coolify-realtime restart: always diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index b72574c8d0..09f5bd5e12 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -8,6 +8,7 @@ import { extractSshArgs, extractTargetHost, extractTimeout, + getTerminalProcessEnv, getTerminalSessionTimeout, isAuthorizedTargetHost, sanitizeSshArgs, @@ -401,7 +402,7 @@ async function handleCommand(ws, command, userId) { cols: 80, rows: 30, cwd: process.env.HOME, - env: {}, + env: getTerminalProcessEnv(), }; // NOTE: - Initiates a process within the Terminal container diff --git a/docker/coolify-realtime/terminal-utils.js b/docker/coolify-realtime/terminal-utils.js index 4e86dc1f73..0d13dc18f1 100644 --- a/docker/coolify-realtime/terminal-utils.js +++ b/docker/coolify-realtime/terminal-utils.js @@ -1,5 +1,13 @@ export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60; +const DEFAULT_TERMINAL_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'; + +export function getTerminalProcessEnv(environment = process.env) { + return { + PATH: environment.PATH || DEFAULT_TERMINAL_PATH, + }; +} + export function getTerminalSessionTimeout() { return MAX_TERMINAL_SESSION_TIMEOUT_SECONDS; } diff --git a/docker/coolify-realtime/terminal-utils.test.js b/docker/coolify-realtime/terminal-utils.test.js index 5ca0be8304..21625eece4 100644 --- a/docker/coolify-realtime/terminal-utils.test.js +++ b/docker/coolify-realtime/terminal-utils.test.js @@ -4,6 +4,7 @@ import { MAX_TERMINAL_SESSION_TIMEOUT_SECONDS, extractSshArgs, extractTargetHost, + getTerminalProcessEnv, getTerminalSessionTimeout, isAuthorizedTargetHost, normalizeHostForAuthorization, @@ -11,6 +12,32 @@ import { validateSshArgs, } from './terminal-utils.js'; +test('getTerminalProcessEnv preserves the PATH needed by SSH proxy commands', () => { + assert.deepEqual(getTerminalProcessEnv({ + PATH: '/usr/local/bin:/usr/bin:/bin', + APP_KEY: 'must-not-be-inherited', + }), { + PATH: '/usr/local/bin:/usr/bin:/bin', + }); +}); + +test('getTerminalProcessEnv uses the default PATH when PATH is absent', () => { + assert.deepEqual(getTerminalProcessEnv({ + APP_KEY: 'must-not-be-inherited', + }), { + PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + }); +}); + +test('getTerminalProcessEnv uses the default PATH when PATH is empty', () => { + assert.deepEqual(getTerminalProcessEnv({ + PATH: '', + APP_KEY: 'must-not-be-inherited', + }), { + PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + }); +}); + test('extractTargetHost normalizes quoted IPv4 hosts from generated ssh commands', () => { const sshArgs = extractSshArgs( "timeout 3600 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ServerAliveInterval=20 -o ConnectTimeout=10 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc" From b351e94c98cf21c1cfba193c0cc457ac6665dcc5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:18:51 +0200 Subject: [PATCH 16/48] fix(api): return task execution duration as float (#11636) --- app/Models/ScheduledTaskExecution.php | 2 +- tests/Feature/ModelFillableCreationTest.php | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/Models/ScheduledTaskExecution.php b/app/Models/ScheduledTaskExecution.php index 1e26c7be3f..8f496fc1e6 100644 --- a/app/Models/ScheduledTaskExecution.php +++ b/app/Models/ScheduledTaskExecution.php @@ -39,7 +39,7 @@ class ScheduledTaskExecution extends BaseModel 'started_at' => 'datetime', 'finished_at' => 'datetime', 'retry_count' => 'integer', - 'duration' => 'decimal:2', + 'duration' => 'float', ]; } diff --git a/tests/Feature/ModelFillableCreationTest.php b/tests/Feature/ModelFillableCreationTest.php index 46d9c36daa..1e06ea22d3 100644 --- a/tests/Feature/ModelFillableCreationTest.php +++ b/tests/Feature/ModelFillableCreationTest.php @@ -1047,14 +1047,15 @@ it('creates ScheduledTaskExecution with all fillable attributes', function () { 'finished_at' => now()->toISOString(), 'started_at' => now()->subMinute()->toISOString(), 'retry_count' => 0, - 'duration' => 60, + 'duration' => '60.25', 'error_details' => null, ]); expect($execution->exists)->toBeTrue(); expect($execution->scheduled_task_id)->toBe($task->id); expect($execution->status)->toBe('success'); - expect((float) $execution->duration)->toBe(60.0); + expect($execution->duration)->toBe(60.25); + expect($execution->toArray()['duration'])->toBe(60.25); expect($execution->retry_count)->toBe(0); }); From 3764771293e4b747930acd34a8d3ddd9d1748e04 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:18:59 +0200 Subject: [PATCH 17/48] fix(webhooks): handle closed PRs after base branch changes (#11634) --- app/Http/Controllers/Webhook/Github.php | 10 +- .../GithubPullRequestWebhookRoutingTest.php | 156 ++++++++++++++++++ 2 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/GithubPullRequestWebhookRoutingTest.php diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 28e92dcd49..c4fdc5fd5c 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -83,7 +83,10 @@ class Github extends Controller } } if ($x_github_event === 'pull_request') { - $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); + if ($action !== 'closed') { + $applications->where('git_branch', $base_branch); + } + $applications = $this->manualWebhookApplications($applications, $full_name); if ($applications->isEmpty()) { return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'."); } @@ -334,7 +337,10 @@ class Github extends Controller } } if ($x_github_event === 'pull_request') { - $applications = $applications->where('git_branch', $base_branch)->get(); + if ($action !== 'closed') { + $applications->where('git_branch', $base_branch); + } + $applications = $applications->get(); if ($applications->isEmpty()) { return response("Nothing to do. No applications found with branch '$base_branch'."); } diff --git a/tests/Feature/GithubPullRequestWebhookRoutingTest.php b/tests/Feature/GithubPullRequestWebhookRoutingTest.php new file mode 100644 index 0000000000..7768dbd903 --- /dev/null +++ b/tests/Feature/GithubPullRequestWebhookRoutingTest.php @@ -0,0 +1,156 @@ +create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ]); + $destination = $server->standaloneDockers()->firstOrFail(); + + return Application::create(array_merge([ + 'name' => 'github-pr-webhook-app', + 'git_repository' => 'https://github.com/test-org/test-repo', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ], $overrides)); +} + +function githubPullRequestPayload(string $action, string $baseBranch): array +{ + return [ + 'action' => $action, + 'number' => 42, + 'repository' => [ + 'id' => 987654321, + 'full_name' => 'test-org/test-repo', + ], + 'pull_request' => [ + 'html_url' => 'https://github.com/test-org/test-repo/pull/42', + 'title' => 'Stacked change', + 'author_association' => 'OWNER', + 'head' => [ + 'ref' => 'feature/child', + 'sha' => 'head-sha', + ], + 'base' => [ + 'ref' => $baseBranch, + ], + ], + ]; +} + +it('routes a closed GitHub App pull request to its application after the base branch changes', function () { + Queue::fake(); + + $team = Team::factory()->create(); + $githubApp = GithubApp::create([ + 'name' => 'github-app-webhook-test', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'app_id' => 1234567890, + 'webhook_secret' => 'test-secret', + 'is_public' => false, + 'team_id' => $team->id, + ]); + $application = createGithubPullRequestWebhookApplication([ + 'repository_project_id' => 987654321, + 'source_id' => $githubApp->id, + 'source_type' => GithubApp::class, + ]); + + $body = json_encode(githubPullRequestPayload('closed', 'feature/parent'), JSON_THROW_ON_ERROR); + $response = $this->call('POST', '/webhooks/source/github/events', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'pull_request', + 'HTTP_X-GitHub-Hook-Installation-Target-Id' => '1234567890', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, 'test-secret'), + 'CONTENT_TYPE' => 'application/json', + ], $body); + + $response->assertOk(); + expect($response->getContent())->toContain('PR webhook received'); + Queue::assertPushed(ProcessGithubPullRequestWebhook::class, fn (ProcessGithubPullRequestWebhook $job): bool => $job->applicationId === $application->id + && $job->action === 'closed' + && $job->pullRequestId === 42); +}); + +it('routes a closed manual pull request to its application after the base branch changes', function () { + Queue::fake(); + + $application = createGithubPullRequestWebhookApplication(); + $payload = githubPullRequestPayload('closed', 'feature/parent'); + $body = json_encode($payload, JSON_THROW_ON_ERROR); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'pull_request', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, $application->manual_webhook_secret_github), + 'CONTENT_TYPE' => 'application/json', + ], $body); + + $response->assertOk(); + Queue::assertPushed(ProcessGithubPullRequestWebhook::class, fn (ProcessGithubPullRequestWebhook $job): bool => $job->applicationId === $application->id + && $job->action === 'closed' + && $job->pullRequestId === 42); +}); + +it('continues to filter non-closed pull requests by base branch', function (string $endpoint) { + Queue::fake(); + + if ($endpoint === 'app') { + $team = Team::factory()->create(); + $githubApp = GithubApp::create([ + 'name' => 'github-app-webhook-test', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'app_id' => 1234567890, + 'webhook_secret' => 'test-secret', + 'is_public' => false, + 'team_id' => $team->id, + ]); + createGithubPullRequestWebhookApplication([ + 'repository_project_id' => 987654321, + 'source_id' => $githubApp->id, + 'source_type' => GithubApp::class, + ]); + + $body = json_encode(githubPullRequestPayload('opened', 'feature/parent'), JSON_THROW_ON_ERROR); + $response = $this->call('POST', '/webhooks/source/github/events', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'pull_request', + 'HTTP_X-GitHub-Hook-Installation-Target-Id' => '1234567890', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, 'test-secret'), + 'CONTENT_TYPE' => 'application/json', + ], $body); + } else { + $application = createGithubPullRequestWebhookApplication(); + $body = json_encode(githubPullRequestPayload('opened', 'feature/parent'), JSON_THROW_ON_ERROR); + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'pull_request', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, $application->manual_webhook_secret_github), + 'CONTENT_TYPE' => 'application/json', + ], $body); + } + + $response->assertOk(); + Queue::assertNotPushed(ProcessGithubPullRequestWebhook::class); +})->with(['app', 'manual']); From 08f68016dd342fe944564f79969f4ceceddc88d6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:58:12 +0200 Subject: [PATCH 18/48] fix(storage): prevent PR suffix dropdown clipping (#11637) --- resources/css/app.css | 5 +- .../views/components/forms/listbox.blade.php | 57 ++++++----- .../project/shared/storages/all.blade.php | 98 +++++++++++-------- .../Feature/ListboxTriggerTruncationTest.php | 7 ++ .../PersistentStorageVolumesLayoutTest.php | 68 +++++++++++++ .../Browser/ApplicationConfigurationTest.php | 56 +++++++++++ 6 files changed, 223 insertions(+), 68 deletions(-) diff --git a/resources/css/app.css b/resources/css/app.css index 40af521c69..06f0b251af 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2673,7 +2673,10 @@ input[type="search"]::-webkit-search-results-decoration { } .volumes-col-backup { - align-items: flex-start; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; } .volumes-cell-actions { diff --git a/resources/views/components/forms/listbox.blade.php b/resources/views/components/forms/listbox.blade.php index d04a5f57e5..4c572596d6 100644 --- a/resources/views/components/forms/listbox.blade.php +++ b/resources/views/components/forms/listbox.blade.php @@ -123,7 +123,8 @@ {{ $attributes->whereStartsWith('x-model') }} {{ $attributes->whereStartsWith('x-effect') }} @if ($preserveValue) wire:ignore @endif - @click.outside="open = false" @keydown.escape="open = false" @resize.window="open && positionPanel()"> + @click.outside="open = false" @keydown.escape="open = false" @resize.window="open && positionPanel()" + @scroll.window.capture="open && positionPanel()"> @if ($portal) -
diff --git a/resources/views/livewire/project/database/backup-edit/s3.blade.php b/resources/views/livewire/project/database/backup-edit/s3.blade.php index 180ff0a169..a06792bb09 100644 --- a/resources/views/livewire/project/database/backup-edit/s3.blade.php +++ b/resources/views/livewire/project/database/backup-edit/s3.blade.php @@ -35,12 +35,12 @@ @endif
- - Back up now diff --git a/resources/views/livewire/project/service/backup-executions.blade.php b/resources/views/livewire/project/service/backup-executions.blade.php index b5c2057cfd..ee992b9a47 100644 --- a/resources/views/livewire/project/service/backup-executions.blade.php +++ b/resources/views/livewire/project/service/backup-executions.blade.php @@ -21,11 +21,17 @@ + @if ($executions->total() > 10) + + + + @endif @if ($executions->isEmpty()) @else -
+
+
TargetTypeScheduleStatusStartedSizeActions
@@ -42,7 +48,13 @@ wire:click="openExecution('{{ $execution['uuid'] }}')" wire:keydown.enter="openExecution('{{ $execution['uuid'] }}')" role="button" tabindex="0" class="data-table-row grid min-w-[820px] cursor-pointer grid-cols-[minmax(150px,1.4fr)_100px_100px_110px_110px_90px_48px] text-left text-[13px] text-neutral-700 dark:text-fg-dim"> - {{ $execution['target'] }} + + {{ $execution['target'] }} + + + {{ $execution['type'] }}{{ $execution['schedule'] }} {{ $execution['started_at']->diffForHumans() }} @@ -58,6 +70,12 @@
@endforeach + @if ($executions->hasPages()) + + @endif
@endif
diff --git a/resources/views/livewire/project/service/volume-backup/index.blade.php b/resources/views/livewire/project/service/volume-backup/index.blade.php index f69d3f0db2..9ac25ec39d 100644 --- a/resources/views/livewire/project/service/volume-backup/index.blade.php +++ b/resources/views/livewire/project/service/volume-backup/index.blade.php @@ -240,7 +240,7 @@ @if ($backups->isNotEmpty() || $databaseBackups->isNotEmpty())
-
+
Target Type @@ -268,6 +268,8 @@ default => 'neutral', }; $databaseBackupId = 'database:'.$databaseBackup->id; + $databaseS3 = $databaseBackup->s3?->team_id === currentTeam()->id ? $databaseBackup->s3 : null; + $databaseS3Tooltip = ! $databaseBackup->save_s3 ? 'S3 storage: Not configured' : ($databaseS3 ? 'S3 storage: '.$databaseS3->name.' (bucket: '.$databaseS3->bucket.')' : 'S3 storage: Unavailable'); @endphp
{{ $databaseBackup->frequency }} - + {{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }} - + Back up now + Settings
@endforeach @@ -297,6 +304,8 @@ @foreach ($backups as $backup) @php $latestExecution = $backup->latestExecution; + $volumeS3 = $backup->s3?->team_id === currentTeam()->id ? $backup->s3 : null; + $volumeS3Tooltip = ! $backup->save_s3 ? 'S3 storage: Not configured' : ($volumeS3 ? 'S3 storage: '.$volumeS3->name.' (bucket: '.$volumeS3->bucket.')' : 'S3 storage: Unavailable'); $status = $latestExecution?->status; $statusLabel = match ($status) { 'running' => 'In progress', @@ -324,17 +333,20 @@ {{ $backup->targetType() }} {{ $backup->frequency }} - - + + {{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }} - + Back up now + Settings
@endforeach diff --git a/resources/views/livewire/server/resources.blade.php b/resources/views/livewire/server/resources.blade.php index 9fb2ca2fcc..78ed0eb01a 100644 --- a/resources/views/livewire/server/resources.blade.php +++ b/resources/views/livewire/server/resources.blade.php @@ -30,9 +30,27 @@ +
+
+ + + +
+
+ +
diff --git a/resources/views/livewire/server/sentinel/logs.blade.php b/resources/views/livewire/server/sentinel/logs.blade.php index 455bb55bb2..37f8367fd1 100644 --- a/resources/views/livewire/server/sentinel/logs.blade.php +++ b/resources/views/livewire/server/sentinel/logs.blade.php @@ -10,15 +10,27 @@ - - - -
- -
+ @if ($server->isSentinelEnabled()) + + + +
+ +
+ @else + + + Enable Sentinel + + + + @endif
diff --git a/resources/views/livewire/shared-variables/server/show.blade.php b/resources/views/livewire/shared-variables/server/show.blade.php index 4bba5eb064..445b6899e9 100644 --- a/resources/views/livewire/shared-variables/server/show.blade.php +++ b/resources/views/livewire/shared-variables/server/show.blade.php @@ -4,7 +4,8 @@
diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php index 42a430a491..03af1bea4f 100644 --- a/tests/Feature/BackupEditValidationTest.php +++ b/tests/Feature/BackupEditValidationTest.php @@ -247,6 +247,7 @@ it('redirects to executions after queuing a database backup with unusable S3 sto 'timeout' => 3600, ]); $database = $backup->database; + $database->update(['status' => 'running:healthy']); $parameters = [ 'project_uuid' => $database->project()->uuid, 'environment_uuid' => $database->environment->uuid, @@ -509,7 +510,7 @@ it('subscribes to database status broadcasts so Backup Now can refresh without a ->toHaveKey('databaseUpdated'); }); -it('shows Backup Now after refresh when the database becomes running', function () { +it('enables Back up now after refresh when the database becomes running', function () { $backup = createBackupForEditValidationTest($this->team, [ 'enabled' => true, ]); @@ -521,17 +522,21 @@ it('shows Backup Now after refresh when the database becomes running', function 'availableS3Storages' => $this->team->s3s, 'status' => 'exited:unhealthy', ]) - ->assertDontSee('Backup Now') + ->assertSee('Back up now') ->assertSet('status', 'exited:unhealthy'); + expect($component->html())->toMatch('/]*wire:click="backupNow"/s'); + $database->update(['status' => 'running:healthy']); $component->call('refreshStatus') ->assertSet('status', 'running:healthy') - ->assertSee('Backup Now'); + ->assertSee('Back up now'); + + expect($component->html())->not->toMatch('/]*wire:click="backupNow"/s'); }); -it('hides Backup Now after refresh when the database stops', function () { +it('disables Back up now after refresh when the database stops', function () { $backup = createBackupForEditValidationTest($this->team, [ 'enabled' => true, ]); @@ -543,12 +548,33 @@ it('hides Backup Now after refresh when the database stops', function () { 'availableS3Storages' => $this->team->s3s, 'status' => 'running:healthy', ]) - ->assertSee('Backup Now') + ->assertSee('Back up now') ->assertSet('status', 'running:healthy'); $database->update(['status' => 'exited:unhealthy']); $component->call('refreshStatus') ->assertSet('status', 'exited:unhealthy') - ->assertDontSee('Backup Now'); + ->assertSee('Back up now'); + + expect($component->html())->toMatch('/]*wire:click="backupNow"/s'); +}); + +it('renders S3 backup selectors outside the scrollable modal', function () { + createS3StorageForBackupEditValidationTest($this->team); + $backup = createBackupForEditValidationTest($this->team); + $html = Livewire::test(BackupEdit::class, [ + 'backup' => $backup->fresh(), + 'availableS3Storages' => $this->team->s3s, + 'section' => 's3', + ])->html(); + + $dom = new DOMDocument; + @$dom->loadHTML($html); + $xpath = new DOMXPath($dom); + foreach (['s3StorageId-panel', 'disableLocalBackup-panel'] as $panelId) { + $panels = $xpath->query('//template[@x-teleport="body"]/div[@id="'.$panelId.'"]'); + expect($panels->length)->toBe(1); + expect($panels->item(0)->getAttribute('style'))->toContain('position: fixed', 'z-index: 9999'); + } }); diff --git a/tests/Feature/BackupNowAvailabilityTest.php b/tests/Feature/BackupNowAvailabilityTest.php new file mode 100644 index 0000000000..7c103e001f --- /dev/null +++ b/tests/Feature/BackupNowAvailabilityTest.php @@ -0,0 +1,132 @@ + 0]); + $team = Team::factory()->create(); + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $resourceAttributes = [ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]; + $this->service = Service::factory()->create(['server_id' => $server->id, ...$resourceAttributes]); + $this->databases = [ + 'standalone' => StandalonePostgresql::create([ + ...$resourceAttributes, + 'name' => 'postgres', + 'postgres_password' => 'password', + ]), + 'service' => ServiceDatabase::create([ + 'service_id' => $this->service->id, + 'name' => 'postgres', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]), + ]; + $this->backups = collect($this->databases)->map(fn ($database) => ScheduledDatabaseBackup::create([ + 'team_id' => $team->id, + 'frequency' => 'daily', + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + ])); + Queue::fake(); +}); + +dataset('database backup controls', [ + 'standalone settings' => [BackupEdit::class, 'standalone'], + 'service settings' => [BackupEdit::class, 'service'], + 'standalone button' => [BackupNow::class, 'standalone'], + 'service button' => [BackupNow::class, 'service'], + 'service list' => [Index::class, 'service'], +]); + +it('only enables and queues database backups for running targets', function (string $componentClass, string $type, string $status, bool $running) { + $database = $this->databases[$type]; + $database->update(['status' => $status]); + $backup = $this->backups[$type]->fresh(); + $parameters = $componentClass === Index::class + ? ['service' => $this->service] + : ['backup' => $backup, ...($componentClass === BackupEdit::class ? ['availableS3Storages' => collect()] : [])]; + $component = Livewire::test($componentClass, $parameters); + $dom = new DOMDocument; + @$dom->loadHTML($component->html()); + $buttons = (new DOMXPath($dom))->query('//button'); + $backupButtons = []; + foreach ($buttons as $button) { + if (str_starts_with($button->getAttribute('wire:click'), 'backupNow') || str_starts_with($button->getAttribute('wire:click.stop'), 'backupNow')) { + $backupButtons[] = $button; + } + } + expect($backupButtons)->toHaveCount(1); + expect($backupButtons[0]->hasAttribute('disabled'))->toBe(! $running); + + $component->call('backupNow', ...($componentClass === Index::class ? ['database', $backup->uuid] : [])); + if ($running) { + Queue::assertPushed(DatabaseBackupJob::class); + } else { + $component->assertDispatched('error')->assertNotDispatched('success')->assertNoRedirect(); + Queue::assertNotPushed(DatabaseBackupJob::class); + } +})->with('database backup controls')->with([ + ['running:healthy', true], + ['running:unhealthy', true], + ['exited:unhealthy', false], + ['restarting:unhealthy', false], +]); + +it('checks current database status before queuing from a stale page', function (string $componentClass, string $type) { + $database = $this->databases[$type]; + $database->update(['status' => 'running:healthy']); + $backup = $this->backups[$type]->fresh(); + $parameters = $componentClass === Index::class + ? ['service' => $this->service] + : ['backup' => $backup, ...($componentClass === BackupEdit::class ? ['availableS3Storages' => collect()] : [])]; + $component = Livewire::test($componentClass, $parameters); + $database->update(['status' => 'exited:unhealthy']); + + $component->call('backupNow', ...($componentClass === Index::class ? ['database', $backup->uuid] : [])) + ->assertDispatched('error')->assertNotDispatched('success')->assertNoRedirect(); + Queue::assertNotPushed(DatabaseBackupJob::class); +})->with('database backup controls'); + +it('refreshes service backup buttons when the service status check completes', function () { + $database = $this->databases['service']; + $database->update(['status' => 'exited:unhealthy']); + $component = Livewire::test(Index::class, ['service' => $this->service]); + $database->update(['status' => 'running:healthy']); + + $component->dispatch('echo-private:team.'.currentTeam()->id.',ServiceChecked'); + expect($component->html())->not->toMatch('/]*wire:click.stop="backupNow/s'); + + $database->update(['status' => 'exited:unhealthy']); + $component->dispatch('echo-private:team.'.currentTeam()->id.',ServiceChecked'); + expect($component->html())->toMatch('/]*wire:click.stop="backupNow/s'); +}); diff --git a/tests/Feature/CreateScheduledBackupValidationTest.php b/tests/Feature/CreateScheduledBackupValidationTest.php index 1b18c31b41..a96e776a05 100644 --- a/tests/Feature/CreateScheduledBackupValidationTest.php +++ b/tests/Feature/CreateScheduledBackupValidationTest.php @@ -82,11 +82,10 @@ it('creates a service database backup without S3 and opens its configuration', f $backup = ScheduledDatabaseBackup::query()->sole(); - $component->assertRedirectToRoute('project.service.database.backup.show', [ + $component->assertRedirectToRoute('project.service.volume-backups.index', [ 'project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid, 'service_uuid' => $service->uuid, - 'stack_service_uuid' => $database->uuid, 'backup_uuid' => $backup->uuid, ]); @@ -114,14 +113,22 @@ it('selects a service database when creating a backup from the unified backups p 'custom_type' => 'postgresql', ]); - Livewire::test(CreateScheduledBackup::class, ['service' => $service]) + $component = Livewire::test(CreateScheduledBackup::class, ['service' => $service]) ->assertSee('Database') ->assertSee('analytics') ->set('selectedDatabaseUuid', $analytics->uuid) ->set('frequency', 'daily') ->call('submit'); - expect(ScheduledDatabaseBackup::query()->sole()->database->is($analytics))->toBeTrue(); + $backup = ScheduledDatabaseBackup::query()->sole(); + expect($backup->database->is($analytics))->toBeTrue(); + + $component->assertRedirectToRoute('project.service.volume-backups.index', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'service_uuid' => $service->uuid, + 'backup_uuid' => $backup->uuid, + ]); }); it('creates a clickhouse backup for its configured database', function () { diff --git a/tests/Feature/Livewire/SentinelLogsTest.php b/tests/Feature/Livewire/SentinelLogsTest.php new file mode 100644 index 0000000000..99d2fa1051 --- /dev/null +++ b/tests/Feature/Livewire/SentinelLogsTest.php @@ -0,0 +1,127 @@ + 0]); + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + session(['currentTeam' => $team]); + $this->actingAs($user); + $this->server = Server::factory()->create(['team_id' => $team->id]); +}); + +it('does not show sync status or fetch logs when sentinel is disabled', function (bool $recentHeartbeat) { + $this->server->sentinelHeartbeat(isReset: ! $recentHeartbeat); + $this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]); + + Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) + ->test(Logs::class) + ->assertSee('Sentinel is disabled') + ->assertSeeHtml('wire:click="enableSentinel"') + ->assertDontSee('Out of sync') + ->assertDontSee('In sync') + ->assertDontSeeLivewire(GetLogs::class); +})->with([false, true]); + +it('shows sync status and logs when sentinel is enabled', function (bool $metricsOnly, bool $recentHeartbeat) { + $this->server->sentinelHeartbeat(isReset: ! $recentHeartbeat); + $this->server->settings()->update([ + 'is_sentinel_enabled' => ! $metricsOnly, + 'is_metrics_enabled' => $metricsOnly, + 'is_build_server' => false, + ]); + + Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) + ->test(Logs::class) + ->assertDontSee('Sentinel is disabled') + ->assertSee($recentHeartbeat ? 'In sync' : 'Out of sync') + ->assertSeeLivewire(GetLogs::class); +})->with([false, true])->with([false, true]); + +it('enables sentinel from the logs page', function () { + $this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]); + StartSentinel::shouldRun()->once()->withArgs(function (Server $server, bool $restart): bool { + expect($server->id)->toBe($this->server->id); + expect($restart)->toBeTrue(); + $server->settings->update(['is_sentinel_enabled' => true]); + + return true; + }); + + Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) + ->test(Logs::class) + ->call('enableSentinel') + ->assertDontSee('Sentinel is disabled') + ->assertDontSee('Enable Sentinel') + ->assertSeeLivewire(GetLogs::class) + ->assertDispatched('refreshServerShow') + ->assertDispatched('success'); + + expect($this->server->fresh()->isSentinelEnabled())->toBeTrue(); +}); + +it('keeps sentinel disabled when startup fails', function () { + $this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]); + StartSentinel::shouldRun()->once()->andThrow(new RuntimeException('Startup failed')); + + Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) + ->test(Logs::class) + ->call('enableSentinel') + ->assertSee('Sentinel is disabled') + ->assertDontSeeLivewire(GetLogs::class) + ->assertDispatched('error') + ->assertNotDispatched('success'); + + expect($this->server->fresh()->isSentinelEnabled())->toBeFalse(); +}); + +it('does not enable sentinel on unsupported servers', function (string $setting) { + $this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false, $setting => true]); + StartSentinel::shouldRun()->never(); + + Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) + ->test(Logs::class) + ->call('enableSentinel') + ->assertSee('Sentinel is disabled') + ->assertDispatched('error'); +})->with(['is_build_server', 'is_swarm_manager', 'is_swarm_worker']); + +it('denies enabling sentinel to members and users outside the server team', function (bool $crossTeam) { + $this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]); + $user = User::factory()->create(); + if (! $crossTeam) { + $this->server->team->members()->attach($user->id, ['role' => 'member']); + } + $this->actingAs($user); + StartSentinel::shouldRun()->never(); + $component = new Logs; + $component->server = $this->server->fresh(); + + expect(fn () => $component->enableSentinel()) + ->toThrow(AuthorizationException::class); + expect($this->server->fresh()->isSentinelEnabled())->toBeFalse(); +})->with([false, true]); + +it('does not restart sentinel when it is already enabled', function () { + $this->server->settings()->update(['is_sentinel_enabled' => true, 'is_build_server' => false]); + StartSentinel::shouldRun()->never(); + + Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) + ->test(Logs::class) + ->assertSeeLivewire(GetLogs::class) + ->call('enableSentinel') + ->assertNotDispatched('success'); +}); diff --git a/tests/Feature/ServerResourcesPaginationTest.php b/tests/Feature/ServerResourcesPaginationTest.php new file mode 100644 index 0000000000..0f9efaae40 --- /dev/null +++ b/tests/Feature/ServerResourcesPaginationTest.php @@ -0,0 +1,141 @@ +component = new Resources; + $this->component->server = Mockery::mock(Server::class)->makePartial(); +}); + +it('paginates sorted resources on both tabs', function (string $tab, string $nameKey) { + $rows = collect(range(23, 1))->map(fn (int $id) => [$nameKey => "Resource $id"]); + $this->component->activeTab = $tab; + if ($tab === 'managed') { + $this->component->server->shouldReceive('definedResources')->andReturn($rows); + } else { + $this->component->unmanagedContainers = $rows->all(); + } + + $page = $this->component->render()->getData()['resources']; + expect($page->total())->toBe(23) + ->and($page->count())->toBe(10) + ->and($page->pluck($nameKey)->all())->toBe(array_map(fn ($id) => "Resource $id", range(1, 10))); + + $this->component->nextPage(); + $page = $this->component->render()->getData()['resources']; + expect($page->currentPage())->toBe(2)->and($page->count())->toBe(10) + ->and($page->first()[$nameKey])->toBe('Resource 11'); + + $this->component->nextPage(); + $page = $this->component->render()->getData()['resources']; + expect($page->count())->toBe(3)->and($page->first()[$nameKey])->toBe('Resource 21'); + + $this->component->previousPage(); + expect($this->component->render()->getData()['resources']->currentPage())->toBe(2); +})->with([['managed', 'name'], ['unmanaged', 'Names']]); + +it('resets pagination and search when switching tabs but preserves them on refresh', function () { + $this->component->server->shouldReceive('refresh')->andReturnSelf(); + $this->component->server->shouldReceive('loadUnmanagedContainers')->andReturn(collect()); + $this->component->setPage(3); + $this->component->search = 'managed resource'; + $this->component->loadUnmanagedContainers(); + expect($this->component->getPage())->toBe(1) + ->and($this->component->search)->toBe(''); + $this->component->setPage(2); + $this->component->search = 'container'; + $this->component->loadUnmanagedContainers(); + expect($this->component->getPage())->toBe(2) + ->and($this->component->search)->toBe('container'); + $this->component->loadManagedContainers(); + expect($this->component->getPage())->toBe(1) + ->and($this->component->search)->toBe(''); + $this->component->setPage(2); + $this->component->search = 'application'; + $this->component->loadManagedContainers(); + expect($this->component->getPage())->toBe(2) + ->and($this->component->search)->toBe('application'); +}); + +it('clamps page size and resets the page', function (int $size, int $expected) { + $this->component->setPage(3); + $this->component->perPage = $size; + $this->component->updatedPerPage(); + expect($this->component->perPage)->toBe($expected) + ->and($this->component->getPage())->toBe(1); +})->with([[25, 25], [0, 1], [200, 100]]); + +it('clamps stale pages after resources disappear including an empty list', function (int $count, int $expectedPage) { + $this->component->activeTab = 'unmanaged'; + $this->component->unmanagedContainers = array_fill(0, $count, ['Names' => 'Container']); + $this->component->setPage(9); + $page = $this->component->render()->getData()['resources']; + expect($page->currentPage())->toBe($expectedPage) + ->and($this->component->getPage())->toBe($expectedPage) + ->and($page->total())->toBe($count); +})->with([[12, 2], [0, 1]]); + +it('renders shared pagination for both resource tabs with stable row identities', function () { + $view = file_get_contents(resource_path('views/livewire/server/resources.blade.php')); + expect($view)->toContain('toContain('toContain('previous-action="previousPage"') + ->toContain('next-action="nextPage"') + ->toContain('wire:key="managed-') + ->toContain('wire:key="unmanaged-') + ->not->toContain('$server->definedResources()'); +}); + +it('searches names across all pages before pagination on both tabs', function (string $tab, string $nameKey) { + $rows = collect(range(1, 25))->map(fn (int $id) => [$nameKey => "Resource $id"]); + $this->component->activeTab = $tab; + if ($tab === 'managed') { + $this->component->server->shouldReceive('definedResources')->andReturn($rows); + } else { + $this->component->unmanagedContainers = $rows->all(); + } + + $this->component->setPage(3); + $this->component->search = ' RESOURCE 2 '; + $this->component->updatedSearch(); + $page = $this->component->render()->getData()['resources']; + expect($page->currentPage())->toBe(1) + ->and($page->total())->toBe(7) + ->and($page->pluck($nameKey)->all())->toBe([ + 'Resource 2', 'Resource 20', 'Resource 21', 'Resource 22', + 'Resource 23', 'Resource 24', 'Resource 25', + ]); + + $this->component->search = 'missing'; + $this->component->updatedSearch(); + expect($this->component->render()->getData()['resources']->total())->toBe(0); + + $this->component->search = ''; + $this->component->updatedSearch(); + $page = $this->component->render()->getData()['resources']; + expect($page->total())->toBe(25)->and($page->currentPage())->toBe(1); + + $this->component->search = ' '; + expect($this->component->render()->getData()['resources']->total())->toBe(25); +})->with([['managed', 'name'], ['unmanaged', 'Names']]); + +it('provides accessible live search and a distinct no-results message', function () { + $view = file_get_contents(resource_path('views/livewire/server/resources.blade.php')); + + expect($view)->toContain('wire:model.live.debounce.300ms="search"') + ->toContain('aria-label="Search resources by name"') + ->toContain('aria-label="Clear search"') + ->toContain('No matching resources') + ->toContain('No matching containers'); +}); + +it('shows search feedback and prevents interaction with stale results while searching', function () { + $view = file_get_contents(resource_path('views/livewire/server/resources.blade.php')); + + expect($view) + ->toContain('') + ->not->toContain('wire:loading.inline-flex wire:target="search"') + ->toContain('wire:loading.class="pointer-events-none opacity-40 blur-[2px]"') + ->toContain('wire:loading.attr="inert" wire:target="search"'); +}); diff --git a/tests/Feature/ServiceResourceRoutingTest.php b/tests/Feature/ServiceResourceRoutingTest.php index 9d5ac5a8a8..ede6fb6fc0 100644 --- a/tests/Feature/ServiceResourceRoutingTest.php +++ b/tests/Feature/ServiceResourceRoutingTest.php @@ -3,14 +3,17 @@ use App\Jobs\DatabaseBackupJob; use App\Jobs\VolumeBackupJob; use App\Livewire\Project\Database\Import as DatabaseImport; +use App\Livewire\Project\Service\BackupExecutions; use App\Livewire\Project\Service\Heading; use App\Livewire\Project\Service\VolumeBackup\Index as ServiceVolumeBackupIndex; use App\Models\Environment; use App\Models\InstanceSettings; use App\Models\LocalPersistentVolume; use App\Models\Project; +use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackupExecution; +use App\Models\ScheduledVolumeBackupExecution; use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; @@ -20,6 +23,7 @@ use App\Models\Team; use App\Models\User; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Once; @@ -234,6 +238,7 @@ test('service database backup schedules open in the Livewire component', functio test('service database backups can be queued from the Livewire component', function () { Queue::fake(); + $this->ownServiceDatabase->update(['status' => 'running:healthy']); $backup = ScheduledDatabaseBackup::create([ 'team_id' => $this->teamA->id, 'frequency' => 'daily', @@ -448,5 +453,294 @@ test('service storage backups page includes schedules from all compose databases ->assertSee('own-db') ->assertSee('analytics-db') ->assertSee("wire:click=\"openSchedule('{$backups->first()->uuid}')\"", false) - ->assertSee("wire:click=\"backupNow('database', '{$backups->first()->uuid}')\"", false); + ->assertSee("wire:click.stop=\"backupNow('database', '{$backups->first()->uuid}')\"", false); +}); + +test('service backup settings open automatically from the creation redirect', function () { + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + + Livewire::withQueryParams(['backup_uuid' => $backup->uuid]) + ->test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService]) + ->assertSet('scheduleModalOpen', true) + ->assertSet('selectedDatabaseBackup.uuid', $backup->uuid) + ->assertSee('S3'); +}); + +test('service backup settings reject a backup belonging to another team', function () { + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamB->id, + 'frequency' => 'daily', + 'database_id' => $this->otherServiceDatabase->id, + 'database_type' => $this->otherServiceDatabase->getMorphClass(), + ]); + + $this->expectException(ModelNotFoundException::class); + + Livewire::withQueryParams(['backup_uuid' => $backup->uuid]) + ->test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService]); +}); + +test('service backups have explicit settings actions for database and storage schedules', function () { + $databaseBackup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + $volume = LocalPersistentVolume::create([ + 'name' => 'service-data', + 'mount_path' => '/data', + 'resource_id' => $this->ownServiceDatabase->id, + 'resource_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + $volumeBackup = $volume->scheduledBackups()->create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + ]); + + $html = Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService])->html(); + $dom = new DOMDocument; + @$dom->loadHTML($html); + $xpath = new DOMXPath($dom); + $buttons = $xpath->query('//button[contains(., "Settings")]'); + $actions = []; + foreach ($buttons as $button) { + $actions[] = $button->getAttribute('wire:click.stop'); + } + + expect($actions)->toContain("openSchedule('{$databaseBackup->uuid}')", "openSchedule('{$volumeBackup->uuid}')"); + + foreach (['database' => $databaseBackup, 'storage' => $volumeBackup] as $type => $backup) { + $backupAction = "wire:click.stop=\"backupNow('{$type}', '{$backup->uuid}')\""; + $settingsAction = "wire:click.stop=\"openSchedule('{$backup->uuid}')\""; + expect(strpos($html, $backupAction))->toBeLessThan(strpos($html, $settingsAction)); + } + +}); + +test('members cannot open service backup settings', function () { + $this->userA->teams()->updateExistingPivot($this->teamA->id, ['role' => 'member']); + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + + Livewire::withQueryParams(['backup_uuid' => $backup->uuid]) + ->test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService]) + ->assertForbidden(); +}); + +test('closing service backup settings clears the backup query parameter', function () { + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + + $component = Livewire::withQueryParams(['backup_uuid' => $backup->uuid, 'search' => 'own-db']) + ->test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService]) + ->assertSet('scheduleModalOpen', true) + ->assertSet('backupUuid', $backup->uuid); + + expect($component->effects['url']['backupUuid']) + ->toMatchArray(['as' => 'backup_uuid', 'use' => 'replace', 'except' => '']); + + $component->dispatch('modalClosed') + ->assertSet('scheduleModalOpen', false) + ->assertSet('selectedDatabaseBackup', null) + ->assertSet('selectedVolumeBackup', null) + ->assertSet('backupUuid', '') + ->assertSet('search', 'own-db') + ->assertNoRedirect(); +}); + +test('service execution history paginates both backup types without truncating older runs', function () { + $schedule = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + $databaseExecutions = collect(range(1, 105))->map(fn ($index) => ScheduledDatabaseBackupExecution::forceCreate([ + 'scheduled_database_backup_id' => $schedule->id, + 'status' => 'success', + 'created_at' => now()->subMinutes($index), + ])); + $volume = LocalPersistentVolume::create([ + 'name' => 'service-data', + 'mount_path' => '/data', + 'resource_id' => $this->ownServiceDatabase->id, + 'resource_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + $volumeSchedule = $volume->scheduledBackups()->create(['team_id' => $this->teamA->id, 'frequency' => 'daily']); + $volumeExecution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $volumeSchedule->id, + 'status' => 'success', + ]); + + $component = Livewire::test(BackupExecutions::class, ['service' => $this->ownService]) + ->assertViewHas('executions', function ($executions) use ($volumeExecution, $databaseExecutions) { + expect($executions)->toBeInstanceOf(LengthAwarePaginator::class) + ->and($executions->total())->toBe(106) + ->and($executions->count())->toBe(10) + ->and($executions->first()['uuid'])->toBe($volumeExecution->uuid) + ->and($executions->last()['uuid'])->toBe($databaseExecutions[8]->uuid); + + return true; + }) + ->assertSeeHtml('aria-label="Next page"'); + + $component->call('openExecution', $volumeExecution->uuid) + ->assertSet('selectedExecution.uuid', $volumeExecution->uuid) + ->call('closeExecutionModal'); + $component->call('nextPage', 'executionsPage') + ->assertViewHas('executions', fn ($executions) => $executions->currentPage() === 2 && $executions->first()['uuid'] === $databaseExecutions[9]->uuid); + $component->call('setPage', 11, 'executionsPage') + ->assertViewHas('executions', fn ($executions) => $executions->count() === 6 && $executions->last()['uuid'] === $databaseExecutions->last()->uuid) + ->call('openExecution', $databaseExecutions->last()->uuid) + ->assertSet('executionModalOpen', true) + ->assertSet('selectedExecution.uuid', $databaseExecutions->last()->uuid); + $component->set('perPage', 25) + ->assertViewHas('executions', fn ($executions) => $executions->currentPage() === 1 && $executions->count() === 25); + $component->call('setPage', 999, 'executionsPage') + ->assertViewHas('executions', fn ($executions) => $executions->currentPage() === 5 && $executions->count() === 6); + $component->set('perPage', 1000)->assertSet('perPage', 100); + $component->set('perPage', 0)->assertSet('perPage', 1); +}); + +test('service execution pagination excludes other teams and denies opening their runs', function (string $type) { + $schedule = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamB->id, + 'frequency' => 'daily', + 'database_id' => $this->otherServiceDatabase->id, + 'database_type' => $this->otherServiceDatabase->getMorphClass(), + ]); + $execution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $schedule->id, + 'status' => 'success', + ]); + + if ($type === 'storage') { + $volume = LocalPersistentVolume::create([ + 'name' => 'other-service-data', + 'mount_path' => '/data', + 'resource_id' => $this->otherServiceDatabase->id, + 'resource_type' => $this->otherServiceDatabase->getMorphClass(), + ]); + $volumeSchedule = $volume->scheduledBackups()->create(['team_id' => $this->teamB->id, 'frequency' => 'daily']); + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $volumeSchedule->id, + 'status' => 'success', + ]); + } + + Livewire::test(BackupExecutions::class, ['service' => $this->ownService]) + ->assertViewHas('executions', fn ($executions) => $executions->isEmpty()) + ->assertDontSeeHtml('aria-label="Next page"') + ->call('openExecution', $execution->uuid) + ->assertNotFound(); +})->with(['database', 'storage']); + +test('execution page size remains adjustable when all runs fit on one page', function () { + $schedule = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + foreach (range(1, 11) as $index) { + $schedule->executions()->create(['status' => 'success']); + } + + Livewire::test(BackupExecutions::class, ['service' => $this->ownService]) + ->set('perPage', 25) + ->assertSeeHtml('aria-label="Items per page"') + ->assertDontSeeHtml('aria-label="Next page"') + ->set('perPage', 10) + ->assertSeeHtml('aria-label="Next page"'); +}); + +test('service backup lists identify the configured S3 storage without extra columns', function () { + foreach (['Cloudflare R2', 'Railway S3', 'Maxio S3'] as $name) { + $volume = LocalPersistentVolume::create([ + 'name' => 'service-data-'.str($name)->slug(), + 'mount_path' => '/data', + 'resource_id' => $this->ownServiceDatabase->id, + 'resource_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + $storage = S3Storage::create(['key' => 'key', 'secret' => 'secret', 'region' => 'auto', 'endpoint' => 'https://s3.example.com', 'team_id' => $this->teamA->id, 'name' => $name, 'bucket' => 'backups']); + ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + 'save_s3' => true, + 's3_storage_id' => $storage->id, + ]); + $volume->scheduledBackups()->create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_id' => $storage->id, + ]); + } + + $html = Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService])->html(); + foreach (['Cloudflare R2', 'Railway S3', 'Maxio S3'] as $name) { + expect(substr_count($html, 'data-tooltip="S3 storage: '.$name.' (bucket: backups)"'))->toBe(2); + } +}); + +test('execution tooltips distinguish current database storage from the recorded storage destination', function () { + $original = S3Storage::create(['key' => 'key', 'secret' => 'secret', 'region' => 'auto', 'endpoint' => 'https://s3.example.com', 'team_id' => $this->teamA->id, 'name' => 'Cloudflare R2', 'bucket' => 'original']); + $current = S3Storage::create(['key' => 'key', 'secret' => 'secret', 'region' => 'auto', 'endpoint' => 'https://s3.example.com', 'team_id' => $this->teamA->id, 'name' => 'Railway S3', 'bucket' => 'current']); + $databaseSchedule = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + 'save_s3' => true, + 's3_storage_id' => $current->id, + ]); + $databaseSchedule->executions()->create(['status' => 'success', 's3_uploaded' => true]); + $volume = LocalPersistentVolume::create([ + 'name' => 'service-data', + 'mount_path' => '/data', + 'resource_id' => $this->ownServiceDatabase->id, + 'resource_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + $volumeSchedule = $volume->scheduledBackups()->create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_id' => $current->id, + ]); + $volumeSchedule->executions()->create(['status' => 'success', 's3_uploaded' => true, 's3_storage_id' => $original->id]); + + Livewire::test(BackupExecutions::class, ['service' => $this->ownService]) + ->assertSeeHtml('data-tooltip="Current schedule S3 storage: Railway S3 (bucket: current)"') + ->assertSeeHtml('data-tooltip="S3 storage: Cloudflare R2 (bucket: original)"'); + + $current->update(['team_id' => $this->teamB->id]); + Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService]) + ->assertDontSee('Railway S3') + ->assertSeeHtml('data-tooltip="S3 storage: Unavailable"'); + Livewire::test(BackupExecutions::class, ['service' => $this->ownService]) + ->assertDontSee('Railway S3') + ->assertSeeHtml('data-tooltip="Current schedule S3 storage: Unavailable"'); + + $databaseSchedule->update(['save_s3' => false]); + $original->delete(); + Livewire::test(BackupExecutions::class, ['service' => $this->ownService]) + ->assertSeeHtml('data-tooltip="Current schedule S3 storage: Not configured"') + ->assertDontSeeHtml('data-tooltip="S3 storage: Cloudflare R2 (bucket: original)"') + ->assertSeeHtml('data-tooltip="S3 storage: Unavailable"'); }); diff --git a/tests/Feature/SharedVariableDevViewTest.php b/tests/Feature/SharedVariableDevViewTest.php index 34767cf06d..9b434ef994 100644 --- a/tests/Feature/SharedVariableDevViewTest.php +++ b/tests/Feature/SharedVariableDevViewTest.php @@ -168,3 +168,38 @@ test('server shared variable dev view updates existing variable', function () { expect($var->value)->toBe('new_value') ->and($var->comment)->toBe('updated comment'); }); + +test('server shared variables display built-ins as read-only rows', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + + Livewire::test(App\Livewire\SharedVariables\Server\Show::class, ['server_uuid' => $server->uuid]) + ->assertSee('COOLIFY_SERVER_UUID') + ->assertSee('COOLIFY_SERVER_NAME') + ->assertSee('Built-in · Read-only') + ->assertDontSee('Add a variable to make it available to resources in this scope.') + ->assertDontSee('data-env-settings-trigger', false) + ->assertSet('variables', '') + ->call('switch') + ->assertSee('COOLIFY_SERVER_UUID') + ->assertSee('COOLIFY_SERVER_NAME') + ->assertSet('variables', '') + ->set('variables', "COOLIFY_SERVER_UUID=changed\nCOOLIFY_SERVER_NAME=changed\nCUSTOM=value") + ->call('submit'); + + expect($server->environment_variables()->pluck('value', 'key')->all()) + ->toMatchArray(['COOLIFY_SERVER_UUID' => $server->uuid, 'COOLIFY_SERVER_NAME' => $server->name, 'CUSTOM' => 'value']); +}); + +test('server built-ins are visible to team members but not other teams', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + + Livewire::test(App\Livewire\SharedVariables\Server\Show::class, ['server_uuid' => $server->uuid]) + ->assertSee('COOLIFY_SERVER_UUID') + ->assertDontSee('Add variable'); + + $otherServer = Server::factory()->create(['team_id' => Team::factory()->create()->id]); + Livewire::test(App\Livewire\SharedVariables\Server\Show::class, ['server_uuid' => $otherServer->uuid]) + ->assertRedirect(route('dashboard')) + ->assertDontSee($otherServer->uuid); +}); From d75881fa96b49f28f00979a18457d9960f05b60a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:29:58 +0200 Subject: [PATCH 25/48] feat(domains): keep domain drafts and move preview settings Preserve in-progress domain edits and redirects across Livewire refreshes, copy www port overrides for service redirect pairs, and relocate preview deployment toggles from Advanced to Previews. Unsaved bars can stay dirty via Alpine while a modal is closed, and service domain tables stack at narrow widths. --- DESIGN.md | 5 +- app/Livewire/Project/Application/Advanced.php | 10 - app/Livewire/Project/Application/Domains.php | 25 +- app/Livewire/Project/Application/Previews.php | 22 ++ app/Livewire/Project/Service/Domains.php | 42 +++- resources/css/app.css | 60 +++++ .../views/components/unsaved-bar.blade.php | 5 +- .../project/application/advanced.blade.php | 14 +- .../project/application/domains.blade.php | 220 +++++++++-------- .../application/partials/domain-row.blade.php | 143 ++++------- .../project/application/previews.blade.php | 23 ++ .../project/service/domains.blade.php | 151 ++++++++---- .../service/partials/domain-table.blade.php | 131 ++++------ tests/Feature/ApplicationDomainsTest.php | 202 +++++++++++++--- .../ApplicationPreviewSettingsTest.php | 139 +++++++++++ tests/Feature/ServiceDomainsTest.php | 224 ++++++++++++++++-- .../Browser/ApplicationConfigurationTest.php | 85 +++++++ tests/v4/Browser/ServiceConfigurationTest.php | 129 ++++++++++ 18 files changed, 1204 insertions(+), 426 deletions(-) create mode 100644 tests/Feature/ApplicationPreviewSettingsTest.php diff --git a/DESIGN.md b/DESIGN.md index a7b26bb666..b27ed68f2d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -520,7 +520,10 @@ Do not restore the old full-width footer. Deferred fields in one Livewire component use one floating unsaved bar and one submit action. Do not add a separate “Save configuration” button to every card. Selectors that are safe to persist independently should use the existing -instant-save pattern. +instant-save pattern. When those requests share a component with a modal draft, +pass the unsaved bar a `dirty` Alpine expression comparing that draft with its +initial values, so unrelated saves do not hide pending changes. Mount modal save +bars only while the modal is open to avoid inactive keyboard shortcuts. --- diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index 45e284c5dc..a9e1c0be28 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -27,12 +27,6 @@ class Advanced extends Component #[Validate(['boolean'])] public bool $isGitShallowCloneEnabled = false; - #[Validate(['boolean'])] - public bool $isPreviewDeploymentsEnabled = false; - - #[Validate(['boolean'])] - public bool $isPrDeploymentsPublicEnabled = false; - #[Validate(['boolean'])] public bool $isAutoDeployEnabled = true; @@ -107,8 +101,6 @@ class Advanced extends Component $this->application->settings->is_git_submodules_enabled = $this->isGitSubmodulesEnabled; $this->application->settings->is_git_lfs_enabled = $this->isGitLfsEnabled; $this->application->settings->is_git_shallow_clone_enabled = $this->isGitShallowCloneEnabled; - $this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled; - $this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled; $this->application->settings->is_auto_deploy_enabled = $this->isAutoDeployEnabled; $this->application->settings->is_log_drain_enabled = $this->isLogDrainEnabled; $this->application->settings->is_gpu_enabled = $this->isGpuEnabled; @@ -136,8 +128,6 @@ class Advanced extends Component $this->isGitSubmodulesEnabled = $this->application->settings->is_git_submodules_enabled; $this->isGitLfsEnabled = $this->application->settings->is_git_lfs_enabled; $this->isGitShallowCloneEnabled = $this->application->settings->is_git_shallow_clone_enabled ?? false; - $this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled; - $this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false; $this->isAutoDeployEnabled = $this->application->settings->is_auto_deploy_enabled; $this->isGpuEnabled = $this->application->settings->is_gpu_enabled; $this->gpuDriver = $this->application->settings->gpu_driver; diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 80eb9bf0a8..906cc147a0 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -150,7 +150,15 @@ class Domains extends Component public function refreshDomains(): void { + $editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null; + $this->loadDomainState(); + + if ($editingRow !== null) { + $index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url'] + && ($row['service'] ?? null) === ($editingRow['service'] ?? null)); + $this->editingIndex = $index === false ? null : (int) $index; + } } public function pollDnsChecks(): void @@ -227,7 +235,9 @@ class Domains extends Component $this->isCompose = $this->application->build_pack === 'dockercompose'; $this->labelsAreWritable = $this->application->settings->is_container_label_readonly_enabled === false; - $this->redirect = $this->application->redirect ?? 'both'; + if ($this->pendingAction !== 'redirect' || $this->isCompose) { + $this->redirect = $this->application->redirect ?? 'both'; + } $this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled(); $settings = instanceSettings(); @@ -254,6 +264,9 @@ class Domains extends Component } $this->composeServices = []; + $pendingRedirect = $this->pendingRedirectService !== null + ? ($this->serviceRedirects[$this->serviceRedirectWireKey($this->pendingRedirectService)] ?? null) + : null; $this->serviceRedirects = []; if ($this->isCompose) { try { @@ -290,7 +303,9 @@ class Domains extends Component $serviceEntry = $domains[$serviceName] ?? null; $storedRedirect = is_array($serviceEntry) ? ($serviceEntry['redirect'] ?? null) : null; $this->serviceRedirects[$this->serviceRedirectWireKey($serviceName)] = $this->normalizeRedirect( - is_string($storedRedirect) ? $storedRedirect : null + $this->pendingAction === 'redirect' && $serviceName === $this->pendingRedirectService + ? $pendingRedirect + : (is_string($storedRedirect) ? $storedRedirect : null) ); } } @@ -973,7 +988,13 @@ class Domains extends Component return; } + $this->authorize('update', $this->application); + $wasRedirect = $this->pendingAction === 'redirect'; $this->pendingAction = null; + $this->pendingRedirectService = null; + if ($wasRedirect) { + $this->refreshDomains(); + } } public function addDomain(): void diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index fa0272bd64..832a0d55cf 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -19,6 +19,10 @@ class Previews extends Component public Application $application; + public bool $isPreviewDeploymentsEnabled = false; + + public bool $isPrDeploymentsPublicEnabled = false; + public string $deployment_uuid; public array $parameters; @@ -41,11 +45,29 @@ class Previews extends Component public function mount() { + $this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled; + $this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false; $this->pull_requests = collect(); $this->parameters = get_route_parameters(); $this->syncDockerTags(); } + public function savePreviewSettings(): void + { + $this->authorize('update', $this->application); + $this->validate([ + 'isPreviewDeploymentsEnabled' => 'boolean', + 'isPrDeploymentsPublicEnabled' => 'boolean', + ]); + + $this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled; + $this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled; + $this->application->settings->save(); + + $this->dispatch('success', 'Settings saved.'); + $this->dispatch('configurationChanged'); + } + private function syncDockerTags(): void { $this->previewDockerTags = []; diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 4ac2f40b2b..b4f76d12d9 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -129,9 +129,17 @@ class Domains extends Component public function refreshDomains(): void { + $editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null; + $this->service->refresh(); $this->service->load(['applications', 'server']); $this->loadDomainState(); + + if ($editingRow !== null) { + $index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url'] + && (int) $row['service_application_id'] === (int) $editingRow['service_application_id']); + $this->editingIndex = $index === false ? null : (int) $index; + } } public function pollDnsChecks(): void @@ -239,9 +247,14 @@ class Domains extends Component ]) ->all(); + $pendingRedirect = $this->serviceRedirects[$this->pendingRedirectServiceApplicationId] ?? null; $this->serviceRedirects = []; foreach ($this->service->applications as $app) { - $this->serviceRedirects[$app->id] = $this->normalizeRedirect($app->redirect ?? null); + $this->serviceRedirects[$app->id] = $this->normalizeRedirect( + $this->pendingAction === 'redirect' && $app->id === $this->pendingRedirectServiceApplicationId + ? $pendingRedirect + : $app->redirect + ); } if ($this->newServiceApplicationId === null && count($this->serviceApps) > 0) { @@ -924,6 +937,7 @@ class Domains extends Component } $toAdd = collect(); + $portOverrides = $app->domain_port_overrides ?? []; foreach ($current as $url) { $counterpart = $this->wwwCounterpartUrl($url, forRedirectPairing: true); if ($counterpart === null) { @@ -940,6 +954,11 @@ class Domains extends Component continue; } + $port = $this->effectiveDomainInternalPort($url, $app); + if ($port['has_port_override']) { + $portOverrides[DomainPortOverrides::withoutPort($counterpart)] = $port['internal_port']; + } + $knownHosts[$hostKey] = true; $toAdd->push($counterpart); } @@ -948,12 +967,13 @@ class Domains extends Component return true; } + $app->domain_port_overrides = $portOverrides ?: null; $merged = $current->merge($toAdd)->unique()->values(); $this->pendingAction = 'redirect'; $this->pendingRedirectServiceApplicationId = $app->id; - // Skip DNS: pairing for redirects must still be configured even when DNS is not ready. - if (! $this->saveDomainListForApp($app, $merged)) { + // Counterparts inherit an existing port, so only domain conflicts need confirmation. + if (! $this->saveDomainListForApp($app, $merged, checkPorts: false)) { return false; } @@ -980,11 +1000,24 @@ class Domains extends Component return; } + if ($this->pendingAction === 'redirect' && $this->pendingRedirectServiceApplicationId) { + $this->setServiceRedirect($this->pendingRedirectServiceApplicationId); + + return; + } + $this->addDomain(); } public function cancelRemovePort(): void { + $this->authorize('update', $this->service); + + if ($this->pendingAction === 'redirect' && $this->pendingRedirectServiceApplicationId) { + $app = $this->findServiceApp($this->pendingRedirectServiceApplicationId); + $this->serviceRedirects[$this->pendingRedirectServiceApplicationId] = $this->normalizeRedirect($app?->redirect); + } + $this->pendingRedirectServiceApplicationId = null; $this->showPortWarningModal = false; $this->forceSaveDomains = false; $this->forceRemovePort = false; @@ -1421,6 +1454,7 @@ class Domains extends Component ServiceApplication $app, Collection $domains, bool $checkConflicts = true, + bool $checkPorts = true, ): bool { $domainString = $domains->filter()->unique()->implode(','); $domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString); @@ -1447,7 +1481,7 @@ class Domains extends Component } } - if (! $this->forceRemovePort) { + if ($checkPorts && ! $this->forceRemovePort) { $requiredPort = $app->getRequiredPort(); if ($requiredPort !== null && $domainString) { $previousFqdn = $app->getOriginal('fqdn'); diff --git a/resources/css/app.css b/resources/css/app.css index 65bce6327d..e3e20cf75e 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -4396,3 +4396,63 @@ a.command-palette-item:focus-visible { .dark .command-palette-arch-badge { color: #fcd34d; } + +/* Service domains prioritize public addresses; configuration lives in settings. */ +#service-domains-section, +.domains-overview-container { + container: service-domains / inline-size; +} + +.service-domains-overview-grid { + grid-template-columns: minmax(0, 1fr) 7.25rem 7.5rem 5.5rem 6.5rem 8rem 6.5rem; + column-gap: 0.75rem; +} + +.data-table-row.service-domains-overview-grid { + padding-block: 0.5rem; +} + +.service-domain-detail { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + font-size: 12px; +} + +.service-domains-overview-grid > span:not(:first-child):not(:last-child) { + text-align: center; +} + +.service-domain-detail-label { + display: none; +} + +.service-domains-https .listbox-trigger { + min-width: 7rem; +} + +@container service-domains (max-width: 980px) { + .data-table-header.service-domains-overview-grid { + display: none; + } + + .data-table-row.service-domains-overview-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + } + + .data-table-row.service-domains-overview-grid > :first-child { + grid-column: 1 / -1; + } + + .service-domain-detail { + justify-content: space-between; + gap: 0.5rem; + } + + .service-domain-detail-label { + display: inline; + color: var(--coollabs-fg-dim); + } +} diff --git a/resources/views/components/unsaved-bar.blade.php b/resources/views/components/unsaved-bar.blade.php index 8d029c2fb3..cbc137e07b 100644 --- a/resources/views/components/unsaved-bar.blade.php +++ b/resources/views/components/unsaved-bar.blade.php @@ -5,6 +5,8 @@ // appears when those fields differ from the last server snapshot — not on // incidental component state (e.g. $wire.set from x-init, display-only props). 'targets' => null, + // Optional Alpine expression for drafts that survive unrelated server requests. + 'dirty' => null, ]) {{-- Floating "unsaved changes" pill (bottom center). Reveals itself via @@ -40,7 +42,8 @@ window.visualViewport?.removeEventListener('scroll', this.updateKeyboardInset); window.removeEventListener('resize', this.updateKeyboardInset); }, -}" x-bind:style="`--keyboard-inset: ${keyboardInset}px`" wire:dirty.class="is-dirty" +}" x-bind:style="`--keyboard-inset: ${keyboardInset}px`" + @if ($dirty) x-bind:class="{ 'is-dirty': {{ $dirty }} }" @else wire:dirty.class="is-dirty" @endif wire:loading.class="is-saving" @keydown.enter.window=" if ($el.classList.contains('is-dirty') && diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index 429689ce7b..c6113808c5 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -55,7 +55,7 @@ @if ($application->git_based()) + helper="Automatic deployments from Git webhooks.">
true, 'label' => 'Deploy on push (webhooks)'], ['value' => false, 'label' => 'Manual deployments only'], ]" :disabled="! $canUpdate" /> - -
diff --git a/resources/views/livewire/project/application/domains.blade.php b/resources/views/livewire/project/application/domains.blade.php index b80085b9d5..93edafde59 100644 --- a/resources/views/livewire/project/application/domains.blade.php +++ b/resources/views/livewire/project/application/domains.blade.php @@ -6,26 +6,31 @@ $composeDomainGroups = collect($domainRows) ->groupBy(fn ($row) => $row['service'] ?? '__unknown') ->filter(fn ($rows) => $rows->contains(fn ($row) => ! ($row['is_suggested'] ?? false))); - $helperText = $isCompose - ? 'Manage domains for every service in this Docker Compose application.' - : 'Manage domains for this application.'; $hasHttpsDomains = collect($domainRows)->contains( fn ($row) => ! ($row['is_suggested'] ?? false) && str_starts_with(strtolower($row['url']), 'https://') ); @endphp - @endif - - @can('update', $application) - - - - Recheck DNS - - - @endcan + @if ($labelsAreWritable) + + Container label readonly mode is disabled. Domains must be set in the Labels section on the General page. + + @endif - @if ($labelsAreWritable) - - Container label readonly mode is disabled. Domains must be set in the Labels section on the General page. - - @endif + @if ($isCompose && count($composeServices) === 0) + + No non-database services found in the Docker Compose file. Domains can only be assigned to application + services. + + @endif - @if ($isCompose && count($composeServices) === 0) - - No non-database services found in the Docker Compose file. Domains can only be assigned to application - services. - - @endif - - @cannot('update', $application) - - You don't have permission to manage domains. Contact your team administrator for access. - - @endcannot - -

- {{ $helperText }} -

- - @if ($hasHttpsDomains && ! $labelsAreWritable) -
- -
- @endif - -
+ @cannot('update', $application) + + You don't have permission to manage domains. Contact your team administrator for access. + + @endcannot {{-- Toolbar --}} -
+
+

Domains

{{ $configuredCount }} domain{{ $configuredCount === 1 ? '' : 's' }} @if ($suggestedCount > 0) @@ -98,7 +76,7 @@

- @if ($isCompose && $composeDomainGroups->isNotEmpty()) + @if ($hasRows)
@@ -107,6 +85,10 @@
@endif @can('update', $application) + + + Check all DNS +
@include('livewire.project.shared.cloudflare-autoconfigure')
@@ -118,7 +100,7 @@ @@ -168,20 +150,46 @@
+ @if ($hasHttpsDomains && ! $labelsAreWritable) +
+ + +
+ +
+
+ @endif + {{-- Table / empty --}}
+ @if ($hasRows) +
+ Domain + Protocol redirect + Domain redirect + Internal port + Search indexing + DNS status + Actions +
+ @endif @if ($isCompose && count($composeServices) === 0 && ! $hasRows) @elseif ($isCompose && $composeDomainGroups->isEmpty()) @elseif (! $hasRows) @elseif ($isCompose) @php @@ -213,36 +221,10 @@ {{ $serviceName }} -
- - @if (auth()->user()?->can('update', $application) && ! $labelsAreWritable) - - @else - - {{ match ($serviceRedirects[$redirectWireKey] ?? 'both') { - 'www' => 'Redirect to www', - 'non-www' => 'Redirect to non-www', - default => 'Allow both', - } }} - - @endif -
-
-
- Domain - DNS Check - Search engine indexing - -
@foreach ($rows as $row) @php $index = collect($domainRows)->search( @@ -256,9 +238,7 @@ 'row' => $row, 'application' => $application, 'labelsAreWritable' => $labelsAreWritable, - 'isCompose' => false, - 'showDirectionControl' => false, - 'domainGridClass' => 'domains-table-grid-service', + 'isCompose' => true, ]) @endforeach
@@ -273,13 +253,6 @@
@else
-
- Domain - DNS Check - Search engine indexing - Direction - -
@foreach ($domainRows as $index => $row) @include('livewire.project.application.partials.domain-row', [ 'index' => $index, @@ -290,10 +263,15 @@ ]) @endforeach
+
+ +
@endif
- {{-- Edit domain modal: open/close is Alpine-only; server runs only on Save / Continue. --}} + {{-- One dialog for address edits and automatically saved domain settings. --}}