diff --git a/.ai/lessons.md b/.ai/lessons.md index fa58e5948..5a2e565f3 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -1,5 +1,15 @@ # Lessons +## Confirm which surface becomes the modal +- When a user wants two settings pages replaced by a modal, identify the parent page that owns the trigger and confirm that the complete child settings view moves into that modal. +- Do not make one child page a modal inside the other child page when the user wants both child URLs removed. +- When the modal itself supplies the title and subtitle, do not repeat page-style section cards inside it. Use a flat input layout and one footer for actions. +- Put destructive actions on the footer's left. Put conversion and the primary Save action on the right, with Save last. +- Do not repeat domain-port guidance in a resource settings modal when domain ports have their own input in the domain editor. +- A flat modal form can still use a bordered summary box for a distinct linked resource, such as the domain count and Manage domains action. +- For compact modal headers, show the descriptive subtitle as hover text on an underlined title instead of adding a second visible line. +- Reuse `x-helper` and the plain `underline underline-offset-4` trigger for title help. Do not use a native `title` tooltip or a dotted underline when the project already has a shared title-tooltip pattern. + ## Alpine x-transition + tw-animate-css exit animations flash at the end - Symptom: a modal/overlay fades out, then flashes fully visible for 1-2 frames before it disappears. - Cause: `animate-out` keyframes default to `animation-fill-mode: none`. The element snaps back to its natural state when the keyframe ends. Alpine hides the element (display: none) only after its own timer (read from `transition-duration`), which starts ~2 rAF later than the animation. The gap shows the element at full opacity. @@ -52,3 +62,23 @@ ## Compare routing identity, not complete domain URLs ## Verify reported fixes against the running development app - When a user asks for before-and-after verification, test the unchanged and fixed production code against the same Jean Run environment. - Cover each requested interface, such as UI and API, and record the exact URL, response, persisted state, and relevant logs. + +## Do not infer that “Pro” means paid +- When the user calls a setting “Pro,” confirm whether it means advanced-user functionality or a subscription entitlement. +- Do not add billing or Cloud-only checks unless the user explicitly requests them. + +## Verify compound status layouts visually +- When a status component can render more than one badge, give its root an explicit horizontal flex layout. +- Inspect the real top-bar layout with every conditional badge visible before calling a UI change complete. + +## Keep a requested security control at its stated scope +- If the user specifies one team-level redaction flag, do not introduce per-secret policy questions. +- Explain storage constraints as implementation details, then preserve the requested single control. + +## Use shared section title helpers in edit modals +- When modal section descriptions should appear on hover, use `x-application.settings-section` instead of a manual heading and visible paragraph. +- Keep text labels for direct actions such as Back up now. Use a standard icon button with a tooltip for familiar secondary actions such as settings. + + +## Keep modal actions in the footer +- When a modal has a large editable body, put preview, validation, and save controls in a fixed footer. Keep the title bar for the title and close action. diff --git a/app/Actions/Service/UpdateServiceApplicationFromApi.php b/app/Actions/Service/UpdateServiceApplicationFromApi.php index 004403975..7357368f9 100644 --- a/app/Actions/Service/UpdateServiceApplicationFromApi.php +++ b/app/Actions/Service/UpdateServiceApplicationFromApi.php @@ -92,6 +92,11 @@ public function execute(ServiceApplication $serviceApplication, Request $request $serviceApplication->is_force_https_enabled = filter_var($payload['is_force_https_enabled'], FILTER_VALIDATE_BOOLEAN); } + if (array_key_exists('max_restart_count', $payload)) { + $serviceApplication->max_restart_count = $payload['max_restart_count']; + $serviceApplication->restart_limit_reached = false; + } + if (array_key_exists('is_log_drain_enabled', $payload)) { $enabled = filter_var($payload['is_log_drain_enabled'], FILTER_VALIDATE_BOOLEAN); $server = $serviceApplication->service->destination->server; diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index 5bf51bc02..c827e818d 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -258,6 +258,7 @@ public function show(Request $request): JsonResponse 'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true), 'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true), 'is_force_https_enabled' => new OA\Property(property: 'is_force_https_enabled', type: 'boolean', nullable: true), + 'max_restart_count' => new OA\Property(property: 'max_restart_count', type: 'integer', minimum: 0, nullable: true, description: 'Maximum Docker restart count before Coolify stops the container. Set to 0 to disable the limit.'), ] ) ) @@ -331,6 +332,7 @@ public function update(Request $request, UpdateServiceApplicationFromApi $update 'is_gzip_enabled', 'is_stripprefix_enabled', 'is_force_https_enabled', + 'max_restart_count', ]; $validationRules = [ @@ -345,6 +347,7 @@ public function update(Request $request, UpdateServiceApplicationFromApi $update 'is_gzip_enabled' => 'sometimes|boolean', 'is_stripprefix_enabled' => 'sometimes|boolean', 'is_force_https_enabled' => 'sometimes|boolean', + 'max_restart_count' => 'sometimes|integer|min:0', ]; $validator = Validator::make($payload, $validationRules); diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index 3b8ca7af1..c73e8bb06 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -20,7 +20,7 @@ class Index extends Component public ?Service $service = null; - public ?ServiceApplication $serviceApplication = null; + public ServiceApplication|ServiceDatabase|null $serviceApplication = null; public ?ServiceDatabase $serviceDatabase = null; @@ -28,6 +28,8 @@ class Index extends Component public ?string $currentRoute = null; + public bool $embedded = false; + public array $parameters; public array $query; @@ -82,6 +84,8 @@ class Index extends Component public bool $isStripprefixEnabled = false; + public mixed $maxRestartCount = 10; + protected $listeners = ['generateDockerCompose', 'refreshScheduledBackups' => '$refresh', 'refreshFileStorages']; protected $rules = [ @@ -93,16 +97,39 @@ class Index extends Component 'publicPortTimeout' => 'nullable|integer|min:1', 'isPublic' => 'required|boolean', 'isLogDrainEnabled' => 'required|boolean', + 'maxRestartCount' => 'integer|min:0', // Application-specific rules 'fqdn' => 'nullable', 'isGzipEnabled' => 'nullable|boolean', 'isStripprefixEnabled' => 'nullable|boolean', ]; - public function mount(?ServiceApplication $serviceApplication = null) - { + public function mount( + ServiceApplication|ServiceDatabase|null $serviceApplication = null, + bool $embedded = false, + ) { try { + $this->embedded = $embedded; $this->services = collect([]); + if ($serviceApplication instanceof ServiceDatabase) { + $this->service = $serviceApplication->service; + $this->authorize('view', $this->service); + $this->parameters = [ + 'project_uuid' => $this->service->environment->project->uuid, + 'environment_uuid' => $this->service->environment->uuid, + 'service_uuid' => $this->service->uuid, + 'stack_service_uuid' => $serviceApplication->uuid, + ]; + $this->query = request()->query(); + $this->currentRoute = 'project.service.index'; + $this->serviceDatabase = $serviceApplication; + $this->serviceApplication = null; + $this->resourceType = 'database'; + $this->initializeDatabaseProperties(); + $this->s3s = currentTeam()->s3s; + + return; + } if ($serviceApplication) { $this->service = $serviceApplication->service; $this->authorize('view', $this->service); @@ -113,6 +140,7 @@ public function mount(?ServiceApplication $serviceApplication = null) 'stack_service_uuid' => $serviceApplication->uuid, ]; $this->query = request()->query(); + $this->currentRoute = 'project.service.index'; $this->serviceApplication = $serviceApplication; $this->resourceType = 'application'; $this->initializeApplicationProperties(); @@ -134,6 +162,12 @@ public function mount(?ServiceApplication $serviceApplication = null) ->firstOrFail(); $this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail(); $this->authorize('view', $this->service); + if (in_array($this->currentRoute, ['project.service.index', 'project.service.index.advanced'], true)) { + return redirect()->route( + 'project.service.configuration', + collect($this->parameters)->except('stack_service_uuid')->all(), + ); + } $service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first(); if ($service) { $this->serviceApplication = $service; @@ -385,6 +419,10 @@ private function syncApplicationData(bool $toModel = false): void $this->serviceApplication->is_log_drain_enabled = $this->isLogDrainEnabled; $this->serviceApplication->is_gzip_enabled = $this->isGzipEnabled; $this->serviceApplication->is_stripprefix_enabled = $this->isStripprefixEnabled; + if ($this->serviceApplication->max_restart_count !== (int) $this->maxRestartCount) { + $this->serviceApplication->restart_limit_reached = false; + } + $this->serviceApplication->max_restart_count = $this->maxRestartCount; } else { $this->humanName = $this->serviceApplication->human_name; $this->description = $this->serviceApplication->description; @@ -394,9 +432,24 @@ private function syncApplicationData(bool $toModel = false): void $this->isLogDrainEnabled = data_get($this->serviceApplication, 'is_log_drain_enabled', false); $this->isGzipEnabled = data_get($this->serviceApplication, 'is_gzip_enabled', true); $this->isStripprefixEnabled = data_get($this->serviceApplication, 'is_stripprefix_enabled', true); + $this->maxRestartCount = $this->serviceApplication->max_restart_count ?? 10; } } + public function saveMaxRestartCount(): void + { + $this->authorize('update', $this->serviceApplication); + $validated = $this->validate([ + 'maxRestartCount' => 'integer|min:0', + ]); + + $this->serviceApplication->update([ + 'max_restart_count' => $validated['maxRestartCount'], + 'restart_limit_reached' => false, + ]); + $this->dispatch('success', 'Max restart count saved.'); + } + public function instantSaveApplication() { try { diff --git a/app/Models/Application.php b/app/Models/Application.php index 2d46291c5..38b8c5b0e 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -131,6 +131,8 @@ class Application extends BaseModel public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; + public const MAX_DOCKER_COMPOSE_COLLECTION_ALIASES = 256; + private static $parserVersion = '5'; protected $fillable = [ @@ -2068,7 +2070,10 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req public function oldRawParser() { try { - $yaml = Yaml::parse($this->docker_compose_raw); + $yaml = Yaml::parse( + $this->docker_compose_raw, + maxAliasesForCollections: self::MAX_DOCKER_COMPOSE_COLLECTION_ALIASES, + ); } catch (\Exception $e) { throw new RuntimeException($e->getMessage()); } diff --git a/openapi.json b/openapi.json index fcc9f34db..718191d3c 100644 --- a/openapi.json +++ b/openapi.json @@ -16816,6 +16816,14 @@ "boolean", "null" ] + }, + "max_restart_count": { + "description": "Maximum Docker restart count before Coolify stops the container. Set to 0 to disable the limit.", + "type": [ + "integer", + "null" + ], + "minimum": 0 } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index c4cea3fc4..a53ab1439 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -10658,6 +10658,10 @@ paths: type: [boolean, 'null'] is_stripprefix_enabled: type: [boolean, 'null'] + max_restart_count: + description: 'Maximum Docker restart count before Coolify stops the container. Set to 0 to disable the limit.' + type: [integer, 'null'] + minimum: 0 type: object responses: '200': diff --git a/resources/views/components/modal-input.blade.php b/resources/views/components/modal-input.blade.php index af21714c4..f5ba4417f 100644 --- a/resources/views/components/modal-input.blade.php +++ b/resources/views/components/modal-input.blade.php @@ -1,5 +1,6 @@ @props([ 'title' => 'Are you sure?', + 'subtitle' => null, 'buttonTitle' => 'Open Modal', 'isErrorButton' => false, 'isHighlightedButton' => false, @@ -60,7 +61,19 @@ class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-[2px]"> ]) style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
-

{{ $title }}

+
+ @if ($subtitle) +

+ + + {{ $title }} + + +

+ @else +

{{ $title }}

+ @endif +
@isset($headerActions)
{{ $headerActions }} @@ -78,6 +91,12 @@ class="order-2 sm:order-none flex size-7 shrink-0 cursor-pointer items-center ju style="-webkit-overflow-scrolling: touch;"> {{ $slot }}
+ @isset($footer) + + @endisset diff --git a/resources/views/components/service-database/sidebar.blade.php b/resources/views/components/service-database/sidebar.blade.php index 756cc6068..c10161910 100644 --- a/resources/views/components/service-database/sidebar.blade.php +++ b/resources/views/components/service-database/sidebar.blade.php @@ -3,25 +3,6 @@ 'serviceDatabase', ]) -@php - $items = [ - [ - 'label' => 'General', - 'route' => 'project.service.index', - 'icon' => 'settings', - 'active' => request()->routeIs('project.service.index'), - ], - [ - 'label' => 'Advanced', - 'route' => 'project.service.index.advanced', - 'icon' => 'grid', - 'active' => request()->routeIs('project.service.index.advanced'), - ], - ]; - - $items = array_values(array_filter($items, fn (array $item): bool => $item['visible'] ?? true)); -@endphp - diff --git a/resources/views/livewire/project/application/status.blade.php b/resources/views/livewire/project/application/status.blade.php index 6d692d36b..7897d095e 100644 --- a/resources/views/livewire/project/application/status.blade.php +++ b/resources/views/livewire/project/application/status.blade.php @@ -1,4 +1,4 @@ -
+
diff --git a/resources/views/livewire/project/database/backup-edit/general.blade.php b/resources/views/livewire/project/database/backup-edit/general.blade.php index 08c64b117..5c794f2aa 100644 --- a/resources/views/livewire/project/database/backup-edit/general.blade.php +++ b/resources/views/livewire/project/database/backup-edit/general.blade.php @@ -1,12 +1,9 @@
-
-
-
-

Backup schedule

-

Choose what to back up, when it runs, and how long it may run.

-
+ +
@if (! $backupEnabled) Back up now
-
+ -
+
@if ($backup->database_type === 'App\Models\StandalonePostgresql' && $backup->database_id !== 0 || $backup->database_type === 'App\Models\StandaloneMysql' || $backup->database_type === 'App\Models\StandaloneMariadb') @@ -96,5 +93,5 @@ class="chip-remove" helper="Notify through backup failure channels after this many days without an execution. Use 0 to disable." required />
-
+ diff --git a/resources/views/livewire/project/database/backup-edit/retention.blade.php b/resources/views/livewire/project/database/backup-edit/retention.blade.php index c16d37101..8825b3c26 100644 --- a/resources/views/livewire/project/database/backup-edit/retention.blade.php +++ b/resources/views/livewire/project/database/backup-edit/retention.blade.php @@ -1,14 +1,9 @@
-
-
-
-

Retention

-

The first reached limit removes the oldest backup. Use 0 for unlimited retention.

-
-
-
+ +

Local backups

@@ -35,5 +30,5 @@
-
+ diff --git a/resources/views/livewire/project/database/status.blade.php b/resources/views/livewire/project/database/status.blade.php index cf2d32c7a..468f855bf 100644 --- a/resources/views/livewire/project/database/status.blade.php +++ b/resources/views/livewire/project/database/status.blade.php @@ -1,4 +1,4 @@ -
+
diff --git a/resources/views/livewire/project/service/advanced-settings.blade.php b/resources/views/livewire/project/service/advanced-settings.blade.php new file mode 100644 index 000000000..7f0b28b37 --- /dev/null +++ b/resources/views/livewire/project/service/advanced-settings.blade.php @@ -0,0 +1,47 @@ +
+ @if ($resourceType === 'application') + @if (str($serviceApplication->image)->contains('pocketbase')) + + @else + + @endif + + + + + @else + + + @endif +
diff --git a/resources/views/livewire/project/service/index.blade.php b/resources/views/livewire/project/service/index.blade.php index acd3f57f7..268eaae49 100644 --- a/resources/views/livewire/project/service/index.blade.php +++ b/resources/views/livewire/project/service/index.blade.php @@ -1,10 +1,12 @@
- -
-
- @if ($resourceType === 'database') + @unless ($embedded) + + @endunless +
! $embedded])> +
! $embedded])> + @if (! $embedded && $resourceType === 'database') - @else + @elseif (! $embedded) @endif
@if ($resourceType === 'application') - - {{ data_get_str($service, 'name')->limit(10) }} > - {{ data_get_str($serviceApplication, 'name')->limit(10) }} | Coolify - - @if ($currentRoute === 'project.service.index.advanced') -
-
-
-

Advanced

-

Control proxy, status, and logging behavior for this compose resource.

-
-
-
- @if (str($serviceApplication->image)->contains('pocketbase')) - - @else - - @endif - - - -
-
- @else + @unless ($embedded) + + {{ data_get_str($service, 'name')->limit(10) }} > + {{ data_get_str($serviceApplication, 'name')->limit(10) }} | Coolify + + @endunless
- -
-
-
-

{{ Str::headline($serviceApplication->human_name ?: $serviceApplication->name) }}

-

Identity, image, and public access for this compose application.

-
-
- @can('update', $serviceApplication) - - @endcan - @can('delete', $serviceApplication) - - @endcan -
-
-
- @if ($requiredPort && !$serviceApplication->serviceType()?->contains(str($serviceApplication->image)->before(':'))) - - This service requires port {{ $requiredPort }} to function correctly. All domains must include this port number (or any other port if you know what you're doing). -

- Example: https://app.coolify.io:{{ $requiredPort }},https://www.app.coolify.io:{{ $requiredPort }} -
- @endif - +
@@ -116,7 +42,8 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3
@if (!$serviceApplication->serviceType()?->contains(str($serviceApplication->image)->before(':'))) -
+

@php($domainCount = countDomains($fqdn)) @@ -140,8 +67,34 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 helper="You can change the image you would like to deploy.

WARNING. You could corrupt your data. Only do it if you know what you are doing." label="Image" id="image">

+
+ + @include('livewire.project.service.advanced-settings') + +
+
+ @can('delete', $serviceApplication) + + @endcan
-
+
+ @can('update', $serviceApplication) + + Save changes + @endcan +
+
@endif - @endif @elseif ($resourceType === 'database') - - {{ data_get_str($service, 'name')->limit(10) }} > - {{ data_get_str($serviceDatabase, 'name')->limit(10) }} | Coolify - + @unless ($embedded) + + {{ data_get_str($service, 'name')->limit(10) }} > + {{ data_get_str($serviceDatabase, 'name')->limit(10) }} | Coolify + + @endunless @if ($currentRoute === 'project.service.database.import') - @elseif ($currentRoute === 'project.service.index.advanced') -
-
-
-

Advanced

-

Control status aggregation and external log delivery.

-
-
-
- - -
-
@else
- -
-
-
-

{{ Str::headline($serviceDatabase->human_name ?: $serviceDatabase->name) }}

-

Identity, image, and public access for this compose database.

-
-
- @can('update', $serviceDatabase) - - @endcan - @can('delete', $serviceDatabase) - - @endcan -
-
-
+
@@ -330,8 +237,35 @@ class="w-auto"> @endif
+
+ + @include('livewire.project.service.advanced-settings') + +
+
+ @can('delete', $serviceDatabase) + + @endcan
-
+
+ @can('update', $serviceDatabase) + + Save changes + @endcan +
+
@endif @endif diff --git a/resources/views/livewire/project/service/resource-card.blade.php b/resources/views/livewire/project/service/resource-card.blade.php index c9106b0fc..b0ca8c56f 100644 --- a/resources/views/livewire/project/service/resource-card.blade.php +++ b/resources/views/livewire/project/service/resource-card.blade.php @@ -1,13 +1,4 @@ -
- @php +@php [$statusType, $statusLabel] = match (true) { str($resource->status)->contains('running') => ['success', formatContainerStatus($resource->status)], str($resource->status)->contains(['starting', 'restarting', 'degraded']) => ['warning', formatContainerStatus($resource->status)], @@ -16,7 +7,20 @@ $resourceName = $resource->human_name ? Str::headline($resource->human_name) : Str::headline($resource->name); - @endphp +@endphp + + + +
@@ -66,10 +70,10 @@ class="flex items-center justify-end gap-1 border-t border-neutral-200 bg-neutra @endcan @endif - + @if (str($resource->status)->contains('running')) @can('update', $service) +
-
+
+ + + @if ($isApplication) + + @else + + @endif + diff --git a/resources/views/livewire/project/service/stack-form.blade.php b/resources/views/livewire/project/service/stack-form.blade.php index d42c2a575..cb8721bd4 100644 --- a/resources/views/livewire/project/service/stack-form.blade.php +++ b/resources/views/livewire/project/service/stack-form.blade.php @@ -11,10 +11,11 @@ @can('update', $service) - +
+ @compose-save-finished.window="saving = false" + class="flex flex-wrap items-center justify-end gap-2"> @@ -33,7 +34,7 @@ Save changes
-
+
@endcan diff --git a/resources/views/livewire/project/service/status.blade.php b/resources/views/livewire/project/service/status.blade.php index 2bdf531b0..cdeb27f08 100644 --- a/resources/views/livewire/project/service/status.blade.php +++ b/resources/views/livewire/project/service/status.blade.php @@ -1,4 +1,4 @@ -
+
@php($displayStatus = $selectedResource?->status ?? $service->status) 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 e73e095a3..d7ea9afca 100644 --- a/resources/views/livewire/project/service/volume-backup/index.blade.php +++ b/resources/views/livewire/project/service/volume-backup/index.blade.php @@ -296,7 +296,11 @@ class="data-table-row backup-table-grid cursor-pointer text-left text-[13px] tex wire:click.stop="backupNow('database', '{{ $databaseBackup->uuid }}')" wire:target="backupNow('database', '{{ $databaseBackup->uuid }}')">Back up now Settings + defaultClass="icon-button shrink-0" :showLoadingIndicator="false" + title="Edit backup schedule" aria-label="Edit backup schedule" + wire:click.stop="openSchedule('{{ $databaseBackup->uuid }}')"> + +
@endforeach @@ -346,7 +350,11 @@ class="data-table-row backup-table-grid cursor-pointer text-left text-[13px] tex wire:click.stop="backupNow('storage', '{{ $backup->uuid }}')" wire:target="backupNow('storage', '{{ $backup->uuid }}')">Back up now Settings + defaultClass="icon-button shrink-0" :showLoadingIndicator="false" + title="Edit backup schedule" aria-label="Edit backup schedule" + wire:click.stop="openSchedule('{{ $backup->uuid }}')"> + +
@endforeach diff --git a/tests/Feature/ComposeEditorLayoutTest.php b/tests/Feature/ComposeEditorLayoutTest.php index d3fe92e45..9aec3bbf1 100644 --- a/tests/Feature/ComposeEditorLayoutTest.php +++ b/tests/Feature/ComposeEditorLayoutTest.php @@ -9,7 +9,8 @@ expect($view) ->toContain('title="Docker Compose"') ->toContain(':isLarge="true"') - ->toContain('') + ->toContain('') + ->not->toContain('') ->toContain("\$dispatch('compose-preview-toggle')") ->toContain("\$dispatch('compose-save')") ->toContain('@compose-save-finished.window="saving = false"') @@ -57,12 +58,12 @@ ->toContain('justify-center p-2') ->toContain('sm:p-4') ->toContain('flex-wrap! sm:flex-nowrap!') - ->toContain('order-3 w-full sm:order-none sm:w-auto') - ->toContain('order-2 sm:order-none') - ->toContain("'mt-2 sm:mt-0' => isset(\$headerActions)"); + ->toContain('@isset($footer)') + ->toContain('justify-end gap-2 border-t') + ->toContain('order-2 sm:order-none'); expect($stackForm) - ->toContain('w-full items-center gap-2 overflow-x-auto sm:w-auto'); + ->toContain('flex-wrap items-center justify-end gap-2'); expect($editor) ->toContain('flex-col items-stretch') diff --git a/tests/Feature/DatabaseBackupsLayoutTest.php b/tests/Feature/DatabaseBackupsLayoutTest.php index 2b4941c09..58c1e5447 100644 --- a/tests/Feature/DatabaseBackupsLayoutTest.php +++ b/tests/Feature/DatabaseBackupsLayoutTest.php @@ -57,3 +57,15 @@ expect($edit) ->toStartWith('
'); }); + +it('shows database backup section descriptions from the shared title helper', function () { + $general = file_get_contents(resource_path('views/livewire/project/database/backup-edit/general.blade.php')); + $retention = file_get_contents(resource_path('views/livewire/project/database/backup-edit/retention.blade.php')); + + expect($general) + ->toContain('not->toContain('

Backup schedule

') + ->and($retention) + ->toContain('not->toContain('

Retention

'); +}); diff --git a/tests/Feature/Service/RestartLimitSettingsTest.php b/tests/Feature/Service/RestartLimitSettingsTest.php new file mode 100644 index 000000000..b1d0213a8 --- /dev/null +++ b/tests/Feature/Service/RestartLimitSettingsTest.php @@ -0,0 +1,86 @@ +withoutVite(); + InstanceSettings::forceCreate(['id' => 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $service = Service::factory()->create([ + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->serviceApplication = ServiceApplication::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'worker', + 'service_id' => $service->id, + 'image' => 'example/worker:latest', + 'max_restart_count' => 10, + ]); +}); + +it('shows and saves the restart limit for a compose application', function () { + $this->serviceApplication->update(['restart_limit_reached' => true]); + + Livewire::test(Index::class, ['serviceApplication' => $this->serviceApplication->fresh()]) + ->assertSet('maxRestartCount', 10) + ->set('currentRoute', 'project.service.index') + ->assertSee('Max restart count') + ->assertSee('Set to 0 to disable the limit.') + ->set('maxRestartCount', 0) + ->call('saveMaxRestartCount') + ->assertHasNoErrors() + ->assertDispatched('success'); + + expect($this->serviceApplication->fresh()) + ->max_restart_count->toBe(0) + ->restart_limit_reached->toBeFalse(); +}); + +it('validates the restart limit', function (mixed $value) { + Livewire::test(Index::class, ['serviceApplication' => $this->serviceApplication]) + ->set('maxRestartCount', $value) + ->call('saveMaxRestartCount') + ->assertHasErrors('maxRestartCount'); +})->with([ + 'negative' => -1, + 'decimal' => 1.5, + 'text' => 'unlimited', +]); + +it('does not let a team member change the restart limit', function () { + $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); + + Livewire::test(Index::class, ['serviceApplication' => $this->serviceApplication]) + ->set('maxRestartCount', 0) + ->call('saveMaxRestartCount') + ->assertForbidden(); + + expect($this->serviceApplication->fresh()->max_restart_count)->toBe(10); +}); diff --git a/tests/Feature/ServiceApplicationsApiTest.php b/tests/Feature/ServiceApplicationsApiTest.php index 3d7309ee3..58f7ea009 100644 --- a/tests/Feature/ServiceApplicationsApiTest.php +++ b/tests/Feature/ServiceApplicationsApiTest.php @@ -259,6 +259,37 @@ function createServiceWithoutApplicationsForApiTest(object $ctx): Service expect($ctx->serviceApplication->fresh()->is_force_https_enabled)->toBeFalse(); }); + test('updates the maximum restart count', function () { + $ctx = createServiceWithApplicationForApiTest($this); + $ctx->serviceApplication->update(['restart_limit_reached' => true]); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [ + 'max_restart_count' => 0, + ])->assertSuccessful() + ->assertJsonPath('max_restart_count', 0) + ->assertJsonPath('restart_limit_reached', false); + + expect($ctx->serviceApplication->fresh()) + ->max_restart_count->toBe(0) + ->restart_limit_reached->toBeFalse(); + }); + + test('rejects an invalid maximum restart count', function (mixed $value) { + $ctx = createServiceWithApplicationForApiTest($this); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [ + 'max_restart_count' => $value, + ])->assertJsonValidationErrors('max_restart_count'); + })->with([ + 'negative' => -1, + 'decimal' => 1.5, + 'text' => 'unlimited', + ]); + test('returns 422 for invalid url scheme', function () { $ctx = createServiceWithApplicationForApiTest($this); diff --git a/tests/Feature/ServiceDatabaseVerticalNavigationTest.php b/tests/Feature/ServiceDatabaseVerticalNavigationTest.php index 2bee610f6..c19e13b79 100644 --- a/tests/Feature/ServiceDatabaseVerticalNavigationTest.php +++ b/tests/Feature/ServiceDatabaseVerticalNavigationTest.php @@ -254,12 +254,15 @@ expect($index) ->toContain('Actions') - ->toContain('
') + ->toContain('
') ->toContain("wire:click.stop=\"backupNow('database',") ->toContain("wire:click.stop=\"backupNow('storage',") ->toContain('toContain('Back up now') - ->not->toContain('class="icon-button shrink-0"') + ->toContain('defaultClass="icon-button shrink-0"') + ->toContain('aria-label="Edit backup schedule"') + ->toContain('') + ->not->toContain('>Settings') ->not->toContain('class="contents cursor-pointer"'); expect($styles) diff --git a/tests/Feature/ServiceResourceSettingsPageTest.php b/tests/Feature/ServiceResourceSettingsPageTest.php new file mode 100644 index 000000000..114b4701b --- /dev/null +++ b/tests/Feature/ServiceResourceSettingsPageTest.php @@ -0,0 +1,133 @@ +withoutVite(); + config()->set('app.maintenance.store', 'array'); + InstanceSettings::forceCreate(['id' => 0]); + + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user, ['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]); + $this->service = Service::factory()->create([ + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->application = ServiceApplication::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'web', + 'service_id' => $this->service->id, + 'image' => 'example/web:latest', + 'status' => 'exited', + ]); + $this->database = ServiceDatabase::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'postgres', + 'service_id' => $this->service->id, + 'image' => 'postgres:17', + 'status' => 'exited', + ]); + $this->parameters = [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'service_uuid' => $this->service->uuid, + ]; +}); + +it('shows application general and advanced settings in a resource card modal', function () { + Livewire::test(ResourceCard::class, [ + 'service' => $this->service, + 'resource' => $this->application, + 'parameters' => $this->parameters, + ]) + ->assertSee('Resource settings') + ->assertSeeHtml('data-icon-tooltip-ignore') + ->assertSeeHtml('underline underline-offset-4') + ->assertDontSeeHtml('decoration-dotted') + ->assertDontSee(route('project.service.index', [ + ...$this->parameters, + 'stack_service_uuid' => $this->application->uuid, + ]), false); + + Livewire::test(Index::class, [ + 'serviceApplication' => $this->application, + 'embedded' => true, + ]) + ->set('requiredPort', 80) + ->assertSee('Save changes') + ->assertDontSee('Required Port: 80') + ->assertSeeHtml('data-domain-summary') + ->assertSeeHtml('data-service-resource-actions') + ->assertSeeInOrder(['Delete', 'Convert to Database', 'Save changes']) + ->assertDontSeeHtml('

General

') + ->assertDontSeeHtml('

Advanced

') + ->assertDontSee("You have changes that haven't been saved yet."); +}); + +it('shows database general and advanced settings in a resource card modal', function () { + Livewire::test(ResourceCard::class, [ + 'service' => $this->service, + 'resource' => $this->database, + 'parameters' => $this->parameters, + ]) + ->assertSee('Resource settings') + ->assertSeeLivewire(Index::class) + ->assertSeeHtml('data-icon-tooltip-ignore') + ->assertSeeHtml('underline underline-offset-4') + ->assertDontSeeHtml('decoration-dotted') + ->assertDontSee(route('project.service.index', [ + ...$this->parameters, + 'stack_service_uuid' => $this->database->uuid, + ]), false); + + Livewire::test(Index::class, [ + 'serviceApplication' => $this->database, + 'embedded' => true, + ]) + ->assertSee('Public access') + ->assertSee('Log drain') + ->assertSee('Save changes') + ->assertSeeHtml('data-service-resource-actions') + ->assertSeeInOrder(['Delete', 'Convert to Application', 'Save changes']) + ->assertDontSeeHtml('

General

') + ->assertDontSeeHtml('

Advanced

') + ->assertDontSee("You have changes that haven't been saved yet."); +}); + +it('redirects old resource settings links to the service configuration page', function (string $routeName, string $resourceUuid) { + $this->get(route($routeName, [ + ...$this->parameters, + 'stack_service_uuid' => $resourceUuid, + ]))->assertRedirect(route('project.service.configuration', $this->parameters)); +})->with([ + 'application general' => fn () => ['project.service.index', $this->application->uuid], + 'application advanced' => fn () => ['project.service.index.advanced', $this->application->uuid], + 'database general' => fn () => ['project.service.index', $this->database->uuid], + 'database advanced' => fn () => ['project.service.index.advanced', $this->database->uuid], +]); diff --git a/tests/Feature/StatusBadgeComponentsTest.php b/tests/Feature/StatusBadgeComponentsTest.php index a69b38358..70324034a 100644 --- a/tests/Feature/StatusBadgeComponentsTest.php +++ b/tests/Feature/StatusBadgeComponentsTest.php @@ -46,6 +46,14 @@ public function __construct(public string $status) {} ->and(substr_count($breadcrumb, 'rounded-full bg-neutral-100'))->toBe(0); }); +it('keeps compound resource statuses on one line in the top breadcrumb', function () { + foreach (['application', 'database', 'service'] as $resourceType) { + $status = file_get_contents(resource_path("views/livewire/project/{$resourceType}/status.blade.php")); + + expect($status)->toContain('class="flex items-center gap-1"'); + } +}); + it('renders resource statuses through reactive livewire components', function () { $breadcrumb = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php')); diff --git a/tests/Unit/DockerComposeAliasLimitTest.php b/tests/Unit/DockerComposeAliasLimitTest.php new file mode 100644 index 000000000..ad255d9b4 --- /dev/null +++ b/tests/Unit/DockerComposeAliasLimitTest.php @@ -0,0 +1,51 @@ +mapWithKeys(fn (int $index): array => ["service-{$index}" => [ + '<<' => '*defaults', + ]]) + ->map(fn (array $service, string $name): string => " {$name}:\n <<: {$service['<<']}\n") + ->implode(''); + + $application = new Application; + $application->docker_compose_raw = "x-defaults: &defaults\n image: alpine:latest\nservices:\n{$services}"; + + $server = new Server; + $server->ip = '127.0.0.1'; + $server->user = 'root'; + + $destination = new StandaloneDocker; + $destination->setRelation('server', $server); + $application->setRelation('destination', $destination); + + $application->oldRawParser(); + + $parsedCompose = Yaml::parse($application->docker_compose_raw); + + expect(data_get($parsedCompose, 'services'))->toHaveCount(129) + ->and(data_get($parsedCompose, 'services.service-129.image'))->toBe('alpine:latest') + ->and(data_get($parsedCompose, 'services.service-129.labels'))->toContain('coolify.managed=true'); +}); + +it('keeps a finite collection alias limit for Docker Compose files', function () { + $services = collect(range(1, Application::MAX_DOCKER_COMPOSE_COLLECTION_ALIASES + 1)) + ->map(fn (int $index): string => " service-{$index}:\n <<: *defaults\n") + ->implode(''); + + $application = new Application; + $application->docker_compose_raw = "x-defaults: &defaults\n image: alpine:latest\nservices:\n{$services}"; + + $application->oldRawParser(); +})->throws(RuntimeException::class, 'Maximum number of collection aliases (256) exceeded'); diff --git a/tests/Unit/ServiceApplicationsOpenApiTest.php b/tests/Unit/ServiceApplicationsOpenApiTest.php index 9d47bddd8..fbc326bc5 100644 --- a/tests/Unit/ServiceApplicationsOpenApiTest.php +++ b/tests/Unit/ServiceApplicationsOpenApiTest.php @@ -28,3 +28,19 @@ ->and($openApi['paths'][$actionPaths[2]]['post']['responses']['200']['content']['application/json']['schema']['properties']) ->toHaveKey('message'); }); + +it('documents the service application restart limit setting', function () { + $openApi = json_decode( + file_get_contents(__DIR__.'/../../openapi.json'), + true, + flags: JSON_THROW_ON_ERROR, + ); + + $properties = $openApi['paths']['/services/{uuid}/applications/{app_uuid}']['patch']['requestBody']['content']['application/json']['schema']['properties']; + + expect($properties['max_restart_count']) + ->toMatchArray([ + 'description' => 'Maximum Docker restart count before Coolify stops the container. Set to 0 to disable the limit.', + 'minimum' => 0, + ]); +});