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:
parent
d24ee35824
commit
fc9e61b7b4
28 changed files with 669 additions and 230 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,12 +1,3 @@
|
|||
<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
|
||||
[$statusType, $statusLabel] = match (true) {
|
||||
str($resource->status)->contains('running') => ['success', formatContainerStatus($resource->status)],
|
||||
|
|
@ -18,6 +9,19 @@
|
|||
: Str::headline($resource->name);
|
||||
@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]">
|
||||
<div class="flex min-w-0 flex-1 items-start gap-3 p-4">
|
||||
|
|
@ -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>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>');
|
||||
});
|
||||
|
|
|
|||
86
tests/Feature/Service/RestartLimitSettingsTest.php
Normal file
86
tests/Feature/Service/RestartLimitSettingsTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
133
tests/Feature/ServiceResourceSettingsPageTest.php
Normal file
133
tests/Feature/ServiceResourceSettingsPageTest.php
Normal 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],
|
||||
]);
|
||||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
51
tests/Unit/DockerComposeAliasLimitTest.php
Normal file
51
tests/Unit/DockerComposeAliasLimitTest.php
Normal 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');
|
||||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue