feat(services): open resource settings in a modal with restart limits

Move service application and database settings into an embedded modal with a footer, subtitle helper, and Docker restart-count control. Accept max_restart_count on the service applications API, cap compose YAML collection aliases, and tighten status, backup, and database sidebar layouts.
This commit is contained in:
Andras Bacsai 2026-09-10 17:39:56 +02:00
parent d24ee35824
commit fc9e61b7b4
28 changed files with 669 additions and 230 deletions

View file

@ -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.

View file

@ -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;

View file

@ -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);

View file

@ -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 {

View file

@ -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());
}

View file

@ -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"

View file

@ -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':

View file

@ -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]"></div>
])
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-wrap! sm:flex-nowrap!">
<h3 class="min-w-0 flex-1 truncate">{{ $title }}</h3>
<div class="min-w-0 flex-1 py-0.5">
@if ($subtitle)
<h3>
<x-helper :helper="$subtitle" :label="'More information about '.$title">
<x-slot:trigger>
<span class="underline underline-offset-4">{{ $title }}</span>
</x-slot:trigger>
</x-helper>
</h3>
@else
<h3 class="truncate">{{ $title }}</h3>
@endif
</div>
@isset($headerActions)
<div class="order-3 w-full sm:order-none sm:w-auto flex shrink-0 items-center gap-2">
{{ $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 }}
</div>
@isset($footer)
<footer
class="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-neutral-200 px-4 py-3 dark:border-white/[0.08]">
{{ $footer }}
</footer>
@endisset
</div>
</div>
</div>

View file

@ -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
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Compose resource settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@ -32,16 +13,10 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3
<span class="menu-item-label">Back to service</span>
</a>
@foreach ($items as $item)
<a @class([
'menu-item',
'menu-item-active' => $item['active'],
])
@if ($item['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($item['route'], $item['parameters'] ?? $parameters) }}">
<x-reicon :name="$item['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $item['label'] }}</span>
<a class="menu-item menu-item-active" {{ wireNavigate() }}
href="{{ route('project.service.index', $parameters) }}">
<x-reicon name="settings" class="menu-item-icon" />
<span class="menu-item-label">General</span>
</a>
@endforeach
</nav>
</aside>

View file

@ -1,4 +1,4 @@
<div wire:poll.10000ms="refreshStatus">
<div wire:poll.10000ms="refreshStatus" class="flex items-center gap-1">
<x-status-summary :status="$application->status" />
<x-application.restart-limit-warning :application="$application" />
</div>

View file

@ -1,12 +1,9 @@
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Backup schedule</h2>
<p>Choose what to back up, when it runs, and how long it may run.</p>
</div>
<x-application.settings-section title="Backup schedule"
description="Choose what to back up, when it runs, and how long it may run.">
<x-slot:actions>
<div class="flex items-center gap-2">
@if (! $backupEnabled)
<x-forms.button type="button" wire:click="toggleEnabled" wire:loading.attr="disabled"
@ -23,9 +20,9 @@
:disabled="! str($status)->startsWith('running')"
:tooltip="! str($status)->startsWith('running') ? 'The database must be running to start a backup.' : null">Back up now</x-forms.button>
</div>
</div>
</x-slot:actions>
<div class="application-settings-section-body space-y-5">
<div class="space-y-5">
@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 />
</div>
</div>
</section>
</x-application.settings-section>
</form>

View file

@ -1,14 +1,9 @@
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Retention</h2>
<p>The first reached limit removes the oldest backup. Use 0 for unlimited retention.</p>
</div>
</div>
<div class="application-settings-section-body space-y-6">
<x-application.settings-section title="Retention"
description="The first reached limit removes the oldest backup. Use 0 for unlimited retention.">
<div class="space-y-6">
<div>
<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">Local backups</h3>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@ -35,5 +30,5 @@
</div>
</div>
</div>
</section>
</x-application.settings-section>
</form>

View file

@ -1,4 +1,4 @@
<div wire:poll.10000ms="refreshStatus">
<div wire:poll.10000ms="refreshStatus" class="flex items-center gap-1">
<x-status-summary :status="$database->status" title="Database status" />
<x-application.restart-limit-warning :application="$database" />
</div>

View file

@ -0,0 +1,47 @@
<div class="grid gap-4 sm:grid-cols-2">
@if ($resourceType === 'application')
@if (str($serviceApplication->image)->contains('pocketbase'))
<x-forms.listbox id="isGzipEnabled" label="Gzip compression"
helper="PocketBase keeps compression disabled so server-sent events continue to work."
:disabled="true" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
@else
<x-forms.listbox id="isGzipEnabled" label="Gzip compression"
:options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
@endif
<x-forms.listbox id="isStripprefixEnabled" label="Path prefixes"
:options="[
['value' => true, 'label' => 'Strip prefixes'],
['value' => false, 'label' => 'Keep prefixes'],
]" />
<x-forms.listbox id="excludeFromStatus" label="Service status"
:options="[
['value' => false, 'label' => 'Include in status'],
['value' => true, 'label' => 'Exclude from status'],
]" />
<x-forms.listbox id="isLogDrainEnabled" label="Log drain"
:options="[
['value' => true, 'label' => 'Send logs to drain'],
['value' => false, 'label' => 'Do not drain logs'],
]" />
<x-forms.input type="number" min="0" id="maxRestartCount" label="Max restart count"
helper="Maximum number of Docker restarts before Coolify stops this container. Set to 0 to disable the limit. Docker counts expected and unexpected restarts."
canGate="update" :canResource="$serviceApplication" />
@else
<x-forms.listbox id="excludeFromStatus" label="Service status"
:options="[
['value' => false, 'label' => 'Include in status'],
['value' => true, 'label' => 'Exclude from status'],
]" />
<x-forms.listbox id="isLogDrainEnabled" label="Log drain"
:options="[
['value' => true, 'label' => 'Send logs to drain'],
['value' => false, 'label' => 'Do not drain logs'],
]" />
@endif
</div>

View file

@ -1,10 +1,12 @@
<div>
@unless ($embedded)
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="$query" />
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
@if ($resourceType === 'database')
@endunless
<section @class(['application-settings-workspace mt-4 w-full max-w-none lg:mt-0' => ! $embedded])>
<div @class(['grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8' => ! $embedded])>
@if (! $embedded && $resourceType === 'database')
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase" />
@else
@elseif (! $embedded)
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Compose resource settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@ -19,95 +21,19 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3
<x-reicon name="settings" class="menu-item-icon" />
<span class="menu-item-label">General</span>
</a>
<a @class(['menu-item', 'menu-item-active' => request()->routeIs('project.service.index.advanced')])
{{ wireNavigate() }} href="{{ route('project.service.index.advanced', $parameters) }}">
<x-reicon name="grid" class="menu-item-icon" />
<span class="menu-item-label">Advanced</span>
</a>
</nav>
</aside>
@endif
<div class="min-w-0">
@if ($resourceType === 'application')
@unless ($embedded)
<x-slot:title>
{{ data_get_str($service, 'name')->limit(10) }} >
{{ data_get_str($serviceApplication, 'name')->limit(10) }} | Coolify
</x-slot>
@if ($currentRoute === 'project.service.index.advanced')
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Advanced</h2>
<p>Control proxy, status, and logging behavior for this compose resource.</p>
</div>
</div>
<div class="application-settings-section-body grid gap-4 sm:grid-cols-2">
@if (str($serviceApplication->image)->contains('pocketbase'))
<x-forms.listbox id="isGzipEnabled" label="Gzip compression"
helper="PocketBase keeps compression disabled so server-sent events continue to work."
:disabled="true" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
@else
<x-forms.listbox id="isGzipEnabled" label="Gzip compression"
onChange="instantSaveApplicationSettings" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
@endif
<x-forms.listbox id="isStripprefixEnabled" label="Path prefixes"
onChange="instantSaveApplicationSettings" :options="[
['value' => true, 'label' => 'Strip prefixes'],
['value' => false, 'label' => 'Keep prefixes'],
]" />
<x-forms.listbox id="excludeFromStatus" label="Service status"
onChange="instantSaveApplicationSettings" :options="[
['value' => false, 'label' => 'Include in status'],
['value' => true, 'label' => 'Exclude from status'],
]" />
<x-forms.listbox id="isLogDrainEnabled" label="Log drain"
onChange="instantSaveApplicationAdvanced" :options="[
['value' => true, 'label' => 'Send logs to drain'],
['value' => false, 'label' => 'Do not drain logs'],
]" />
</div>
</section>
@else
@endunless
<form wire:submit="submitApplication" class="space-y-6">
<x-unsaved-bar action="submitApplication" />
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>{{ Str::headline($serviceApplication->human_name ?: $serviceApplication->name) }}</h2>
<p>Identity, image, and public access for this compose application.</p>
</div>
<div class="flex items-center gap-2">
@can('update', $serviceApplication)
<x-modal-confirmation wire:click="convertToDatabase" title="Convert to Database"
buttonTitle="Convert to Database" submitAction="convertToDatabase" :actions="['The selected resource will be converted to a service database.']"
confirmationText="{{ Str::headline($serviceApplication->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Application Name below"
shortConfirmationLabel="Service Application Name" />
@endcan
@can('delete', $serviceApplication)
<x-modal-confirmation title="Confirm Service Application Deletion?" buttonTitle="Delete" isErrorButton
submitAction="deleteApplication" :actions="['The selected service application container will be stopped and permanently deleted.']"
confirmationText="{{ Str::headline($serviceApplication->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Application Name below"
shortConfirmationLabel="Service Application Name" />
@endcan
</div>
</div>
<div class="application-settings-section-body space-y-4">
@if ($requiredPort && !$serviceApplication->serviceType()?->contains(str($serviceApplication->image)->before(':')))
<x-callout type="info" title="Required Port: {{ $requiredPort }}" class="mb-2">
This service requires port <strong>{{ $requiredPort }}</strong> to function correctly. All domains must include this port number (or any other port if you know what you're doing).
<br><br>
<strong>Example:</strong> https://app.coolify.io:{{ $requiredPort }},https://www.app.coolify.io:{{ $requiredPort }}
</x-callout>
@endif
<div class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.input canGate="update" :canResource="$serviceApplication" label="Name" id="humanName"
placeholder="Human readable name"></x-forms.input>
@ -116,7 +42,8 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3
</div>
<div class="grid gap-4 sm:grid-cols-2">
@if (!$serviceApplication->serviceType()?->contains(str($serviceApplication->image)->before(':')))
<div class="rounded-lg border border-neutral-200 p-4 dark:border-white/[0.08]">
<div data-domain-summary
class="rounded-lg border border-neutral-200 p-4 dark:border-white/[0.08]">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-sm text-neutral-500 dark:text-fg-dim">
@php($domainCount = countDomains($fqdn))
@ -141,7 +68,33 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3
label="Image" id="image"></x-forms.input>
</div>
</div>
</section>
@include('livewire.project.service.advanced-settings')
<div data-service-resource-actions
class="flex flex-wrap items-center justify-between gap-3 border-t border-neutral-200 pt-5 dark:border-white/[0.08]">
<div>
@can('delete', $serviceApplication)
<x-modal-confirmation title="Confirm Service Application Deletion?" buttonTitle="Delete"
isErrorButton submitAction="deleteApplication"
:actions="['The selected service application container will be stopped and permanently deleted.']"
confirmationText="{{ Str::headline($serviceApplication->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Application Name below"
shortConfirmationLabel="Service Application Name" />
@endcan
</div>
<div class="ml-auto flex items-center gap-2">
@can('update', $serviceApplication)
<x-modal-confirmation wire:click="convertToDatabase" title="Convert to Database"
buttonTitle="Convert to Database" submitAction="convertToDatabase"
:actions="['The selected resource will be converted to a service database.']"
confirmationText="{{ Str::headline($serviceApplication->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Application Name below"
shortConfirmationLabel="Service Application Name" />
<x-forms.button type="submit" isHighlighted>Save changes</x-forms.button>
@endcan
</div>
</div>
</form>
<x-domain-conflict-modal
@ -212,64 +165,18 @@ class="w-auto">
</template>
</div>
@endif
@endif
@elseif ($resourceType === 'database')
@unless ($embedded)
<x-slot:title>
{{ data_get_str($service, 'name')->limit(10) }} >
{{ data_get_str($serviceDatabase, 'name')->limit(10) }} | Coolify
</x-slot>
@endunless
@if ($currentRoute === 'project.service.database.import')
<livewire:project.database.import :resource="$serviceDatabase" :key="'import-' . $serviceDatabase->uuid" />
@elseif ($currentRoute === 'project.service.index.advanced')
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Advanced</h2>
<p>Control status aggregation and external log delivery.</p>
</div>
</div>
<div class="application-settings-section-body grid gap-4 sm:grid-cols-2">
<x-forms.listbox id="excludeFromStatus" label="Service status"
onChange="instantSaveExclude" :options="[
['value' => false, 'label' => 'Include in status'],
['value' => true, 'label' => 'Exclude from status'],
]" />
<x-forms.listbox id="isLogDrainEnabled" label="Log drain"
onChange="instantSaveLogDrain" :options="[
['value' => true, 'label' => 'Send logs to drain'],
['value' => false, 'label' => 'Do not drain logs'],
]" />
</div>
</section>
@else
<form wire:submit="submitDatabase" class="space-y-6">
<x-unsaved-bar action="submitDatabase" />
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>{{ Str::headline($serviceDatabase->human_name ?: $serviceDatabase->name) }}</h2>
<p>Identity, image, and public access for this compose database.</p>
</div>
<div class="flex items-center gap-2">
@can('update', $serviceDatabase)
<x-modal-confirmation wire:click="convertToApplication" title="Convert to Application"
buttonTitle="Convert to Application" submitAction="convertToApplication" :actions="['The selected resource will be converted to an application.']"
confirmationText="{{ Str::headline($serviceDatabase->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Database Name below"
shortConfirmationLabel="Service Database Name" />
@endcan
@can('delete', $serviceDatabase)
<x-modal-confirmation title="Confirm Service Database Deletion?" buttonTitle="Delete"
isErrorButton submitAction="deleteDatabase" :actions="[
'The selected service database container will be stopped and permanently deleted.',
]"
confirmationText="{{ Str::headline($serviceDatabase->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Database Name below"
shortConfirmationLabel="Service Database Name" />
@endcan
</div>
</div>
<div class="application-settings-section-body space-y-5">
<div class="space-y-5">
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.input canGate="update" :canResource="$serviceDatabase" label="Name" id="humanName"
placeholder="Name"></x-forms.input>
@ -331,7 +238,34 @@ class="w-auto">
</div>
</div>
</div>
</section>
@include('livewire.project.service.advanced-settings')
<div data-service-resource-actions
class="flex flex-wrap items-center justify-between gap-3 border-t border-neutral-200 pt-5 dark:border-white/[0.08]">
<div>
@can('delete', $serviceDatabase)
<x-modal-confirmation title="Confirm Service Database Deletion?" buttonTitle="Delete"
isErrorButton submitAction="deleteDatabase" :actions="[
'The selected service database container will be stopped and permanently deleted.',
]"
confirmationText="{{ Str::headline($serviceDatabase->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Database Name below"
shortConfirmationLabel="Service Database Name" />
@endcan
</div>
<div class="ml-auto flex items-center gap-2">
@can('update', $serviceDatabase)
<x-modal-confirmation wire:click="convertToApplication" title="Convert to Application"
buttonTitle="Convert to Application" submitAction="convertToApplication"
:actions="['The selected resource will be converted to an application.']"
confirmationText="{{ Str::headline($serviceDatabase->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Database Name below"
shortConfirmationLabel="Service Database Name" />
<x-forms.button type="submit" isHighlighted>Save changes</x-forms.button>
@endcan
</div>
</div>
</form>
@endif
@endif

View file

@ -1,13 +1,4 @@
<div x-data="{
settingsUrl: @js(route('project.service.index', [...$parameters, 'stack_service_uuid' => $resource->uuid])),
openSettings(event) {
if (event.target.closest('a, button')) {
return;
}
Livewire.navigate(this.settingsUrl);
}
}">
@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
<x-modal-input title="{{ $resourceName }}"
subtitle="{{ $isApplication ? 'Identity, image, and public access for this compose application.' : 'Identity, image, and public access for this compose database.' }}"
:contentClicks="false" :wireIgnore="false" isLarge>
<x-slot:content>
<div x-data="{
openSettings(event) {
if (event.target.closest('a, button')) {
return;
}
modalOpen = true;
}
}">
<div x-cloak x-show="viewMode === 'grid'"
class="group flex min-w-0 flex-col overflow-hidden rounded-[10px] border border-neutral-200 bg-white transition-[border-color,background-color,box-shadow] hover:border-neutral-300 hover:shadow-sm dark:border-white/[0.07] dark:bg-surface dark:hover:border-white/[0.12] dark:hover:bg-white/[0.035]">
@ -66,10 +70,10 @@ class="flex items-center justify-end gap-1 border-t border-neutral-200 bg-neutra
</a>
@endcan
@endif
<a class="icon-button" title="Resource settings" aria-label="Resource settings" {{ wireNavigate() }}
href="{{ route('project.service.index', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
<button type="button" class="icon-button" title="Resource settings" aria-label="Resource settings"
@click="modalOpen = true">
<x-reicon name="settings" class="size-4" />
</a>
</button>
@if (str($resource->status)->contains('running'))
@can('update', $service)
<x-modal-confirmation
@ -119,10 +123,20 @@ class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-neutral-10
</a>
@endcan
@endif
<a class="icon-button" title="Resource settings" aria-label="Resource settings" {{ wireNavigate() }}
href="{{ route('project.service.index', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
<button type="button" class="icon-button" title="Resource settings" aria-label="Resource settings"
@click="modalOpen = true">
<x-reicon name="settings" class="size-4" />
</a>
</button>
</div>
</div>
</div>
</div>
</x-slot:content>
@if ($isApplication)
<livewire:project.service.index :serviceApplication="$resource" :embedded="true"
wire:key="service-application-settings-{{ $resource->id }}" lazy />
@else
<livewire:project.service.index :serviceApplication="$resource" :embedded="true"
wire:key="service-database-settings-{{ $resource->id }}" lazy />
@endif
</x-modal-input>

View file

@ -11,10 +11,11 @@
@can('update', $service)
<x-modal-input buttonTitle="Edit Compose file" title="Docker Compose" :closeOutside="false"
:isLarge="true">
<x-slot:headerActions>
<x-slot:footer>
<div x-data="{ preview: false, validating: false, saving: false }"
@compose-validate-finished.window="validating = false"
@compose-save-finished.window="saving = false" class="flex w-full items-center gap-2 overflow-x-auto sm:w-auto">
@compose-save-finished.window="saving = false"
class="flex flex-wrap items-center justify-end gap-2">
<x-forms.button
@click="preview = !preview; $dispatch('compose-preview-toggle')">
<x-reicon name="eye" class="size-3.5" />
@ -33,7 +34,7 @@
Save changes
</x-forms.button>
</div>
</x-slot:headerActions>
</x-slot:footer>
<livewire:project.service.edit-compose serviceId="{{ $service->id }}" />
</x-modal-input>
@endcan

View file

@ -1,4 +1,4 @@
<div wire:poll.10000ms="refreshStatus">
<div wire:poll.10000ms="refreshStatus" class="flex items-center gap-1">
@php($displayStatus = $selectedResource?->status ?? $service->status)
<x-status-summary :status="$displayStatus" :title="$selectedResource ? 'Resource status' : 'Service status'"
:container-name="$selectedResource ? 'Container' : 'Containers'" />

View file

@ -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</x-forms.button>
<x-forms.button type="button" canGate="update" :canResource="$service"
wire:click.stop="openSchedule('{{ $databaseBackup->uuid }}')">Settings</x-forms.button>
defaultClass="icon-button shrink-0" :showLoadingIndicator="false"
title="Edit backup schedule" aria-label="Edit backup schedule"
wire:click.stop="openSchedule('{{ $databaseBackup->uuid }}')">
<x-reicon name="settings" class="size-4" />
</x-forms.button>
</span>
</div>
@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</x-forms.button>
<x-forms.button type="button" canGate="update" :canResource="$service"
wire:click.stop="openSchedule('{{ $backup->uuid }}')">Settings</x-forms.button>
defaultClass="icon-button shrink-0" :showLoadingIndicator="false"
title="Edit backup schedule" aria-label="Edit backup schedule"
wire:click.stop="openSchedule('{{ $backup->uuid }}')">
<x-reicon name="settings" class="size-4" />
</x-forms.button>
</span>
</div>
@endforeach

View file

@ -9,7 +9,8 @@
expect($view)
->toContain('title="Docker Compose"')
->toContain(':isLarge="true"')
->toContain('<x-slot:headerActions>')
->toContain('<x-slot:footer>')
->not->toContain('<x-slot:headerActions>')
->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')

View file

@ -57,3 +57,15 @@
expect($edit)
->toStartWith('<div class="flex flex-col gap-6">');
});
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('<x-application.settings-section title="Backup schedule"')
->not->toContain('<h2>Backup schedule</h2>')
->and($retention)
->toContain('<x-application.settings-section title="Retention"')
->not->toContain('<h2>Retention</h2>');
});

View file

@ -0,0 +1,86 @@
<?php
use App\Livewire\Project\Service\Index;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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);
});

View file

@ -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);

View file

@ -254,12 +254,15 @@
expect($index)
->toContain('<span class="text-right">Actions</span>')
->toContain('<div class="min-w-[59rem]">')
->toContain('<div class="min-w-[64rem]">')
->toContain("wire:click.stop=\"backupNow('database',")
->toContain("wire:click.stop=\"backupNow('storage',")
->toContain('<x-forms.button')
->toContain('Back up now</x-forms.button>')
->not->toContain('class="icon-button shrink-0"')
->toContain('defaultClass="icon-button shrink-0"')
->toContain('aria-label="Edit backup schedule"')
->toContain('<x-reicon name="settings" class="size-4" />')
->not->toContain('>Settings</x-forms.button>')
->not->toContain('class="contents cursor-pointer"');
expect($styles)

View file

@ -0,0 +1,133 @@
<?php
use App\Livewire\Project\Service\Index;
use App\Livewire\Project\Service\ResourceCard;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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('<h2>General</h2>')
->assertDontSeeHtml('<h2>Advanced</h2>')
->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('<h2>General</h2>')
->assertDontSeeHtml('<h2>Advanced</h2>')
->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],
]);

View file

@ -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'));

View file

@ -0,0 +1,51 @@
<?php
use App\Models\Application;
use App\Models\Server;
use App\Models\StandaloneDocker;
use Illuminate\Support\Facades\Process;
use Symfony\Component\Yaml\Yaml;
use Tests\TestCase;
uses(TestCase::class);
it('parses valid Docker Compose files with more than 128 collection aliases', function () {
Process::fake();
$services = collect(range(1, 129))
->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');

View file

@ -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,
]);
});