mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-12 15:53:46 -04:00
fix(ui): load the Compose file on open and show a disabled Deploy until it loads
Load a missing Compose file once when an application page opens. Until it loads, show Load compose next to a disabled Deploy button on every page. Keep teleported tooltips keyed so Livewire morphs the form after updates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
94e70983f4
commit
d30f582bfe
@@ -155,6 +155,7 @@ class General extends Component
|
||||
'resetDefaultLabels',
|
||||
'configurationChanged' => '$refresh',
|
||||
'confirmDomainUsage',
|
||||
'composeFileLoaded' => 'syncLoadedComposeFile',
|
||||
];
|
||||
|
||||
protected function rules(): array
|
||||
@@ -575,18 +576,7 @@ class General extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
$this->parsedServices = redactLockedValuesInGeneratedCompose($this->parsedServices, $this->application);
|
||||
|
||||
// Refresh parsedServiceDomains to reflect any changes in docker_compose_domains
|
||||
$this->application->refresh();
|
||||
|
||||
// Sync the docker_compose_raw from the model to the component property
|
||||
// This ensures the Monaco editor displays the loaded compose file
|
||||
$this->syncData();
|
||||
|
||||
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
|
||||
// Convert service names with dots and dashes to use underscores for HTML form binding
|
||||
$this->parsedServiceDomains = $this->sanitizeParsedServiceDomainsForForm($this->parsedServiceDomains);
|
||||
$this->showLoadedComposeFile();
|
||||
|
||||
$showToast && $this->dispatch('success', 'Docker compose file loaded.');
|
||||
$this->dispatch('compose_loaded');
|
||||
@@ -605,6 +595,34 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a Compose file that the heading loaded while this form is open.
|
||||
*/
|
||||
public function syncLoadedComposeFile(): void
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
$this->application->refresh();
|
||||
$this->parsedServices = $this->application->parse();
|
||||
$this->showLoadedComposeFile();
|
||||
}
|
||||
|
||||
private function showLoadedComposeFile(): void
|
||||
{
|
||||
$this->parsedServices = redactLockedValuesInGeneratedCompose($this->parsedServices, $this->application);
|
||||
|
||||
// Refresh parsedServiceDomains to reflect any changes in docker_compose_domains
|
||||
$this->application->refresh();
|
||||
|
||||
// Sync the docker_compose_raw from the model to the component property
|
||||
// This ensures the Monaco editor displays the loaded compose file
|
||||
$this->syncData();
|
||||
|
||||
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
|
||||
// Convert service names with dots and dashes to use underscores for HTML form binding
|
||||
$this->parsedServiceDomains = $this->sanitizeParsedServiceDomainsForForm($this->parsedServiceDomains);
|
||||
}
|
||||
|
||||
public function generateDomain(string $serviceName)
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -4,10 +4,13 @@ namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Actions\Application\StopApplication;
|
||||
use App\Actions\Docker\GetContainersStatus;
|
||||
use App\Livewire\Project\Service\Storage;
|
||||
use App\Livewire\Project\Shared\EnvironmentVariable\All;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Traits\ListensToTeamChannel;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class Heading extends Component
|
||||
@@ -29,10 +32,18 @@ class Heading extends Component
|
||||
|
||||
public bool $docker_cleanup = true;
|
||||
|
||||
/**
|
||||
* Set once on page open, so that a later switch to the Compose build pack does not load the file
|
||||
* before the user confirms its location.
|
||||
*/
|
||||
#[Locked]
|
||||
public bool $loadComposeOnOpen = false;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
return [
|
||||
'compose_loaded' => '$refresh',
|
||||
'configurationChanged' => '$refresh',
|
||||
'update_links' => '$refresh',
|
||||
...$this->teamChannelListeners([
|
||||
'ServiceStatusChanged' => 'checkStatus',
|
||||
@@ -53,6 +64,39 @@ class Heading extends Component
|
||||
$lastDeployment = $this->application->get_last_successful_deployment();
|
||||
$this->lastDeploymentInfo = data_get_str($lastDeployment, 'commit')->limit(7).' '.data_get($lastDeployment, 'commit_message');
|
||||
$this->lastDeploymentLink = $this->application->gitCommitLink(data_get($lastDeployment, 'commit'));
|
||||
$this->loadComposeOnOpen = $this->needsComposeFile() && auth()->user()?->can('update', $this->application);
|
||||
}
|
||||
|
||||
public function needsComposeFile(): bool
|
||||
{
|
||||
return $this->application->build_pack === 'dockercompose' && is_null($this->application->docker_compose_raw);
|
||||
}
|
||||
|
||||
public function loadComposeFile(bool $isInit = false)
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
if ($isInit && ! $this->needsComposeFile()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$loaded = $this->application->loadComposeFile();
|
||||
if (is_null(data_get($loaded, 'parsedServices'))) {
|
||||
$this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('success', 'Docker compose file loaded.');
|
||||
$this->dispatch('composeFileLoaded')->to(General::class);
|
||||
$this->dispatch('storageCountsChanged')->to(Storage::class);
|
||||
$this->dispatch('refreshEnvs')->to(All::class);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->application->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,7 +170,7 @@ class Heading extends Component
|
||||
try {
|
||||
$this->authorize('deploy', $this->application);
|
||||
|
||||
if ($this->application->build_pack === 'dockercompose' && is_null($this->application->docker_compose_raw)) {
|
||||
if ($this->needsComposeFile()) {
|
||||
$this->dispatch('error', 'Failed to deploy', 'Please load a Compose file first.');
|
||||
|
||||
return;
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
@if ($authDisabled || filled($tooltip))
|
||||
{{-- teleported so a dropdown or other stacking context cannot hide or move it --}}
|
||||
<template x-teleport="body">
|
||||
<div x-ref="tip" x-show="visible" x-cloak :id="$id('button-tooltip')" role="tooltip"
|
||||
<div x-ref="tip" x-show="visible" x-cloak :id="$id('button-tooltip')" wire:key="button-tooltip" role="tooltip"
|
||||
x-transition:enter="transition-opacity ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition-opacity ease-in duration-75"
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
</button>
|
||||
{{-- teleported so a dropdown or other stacking context cannot put it under the sidebar --}}
|
||||
<template x-teleport="body">
|
||||
<div x-ref="popup" x-show="open" x-cloak :id="$id('helper-popup')" role="tooltip"
|
||||
<div x-ref="popup" x-show="open" x-cloak :id="$id('helper-popup')" wire:key="helper-popup" role="tooltip"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 translate-y-0.5"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
targets="name,description,buildPack,staticImage,baseDirectory,dockerComposeLocation,dockerComposeCustomBuildCommand,dockerComposeCustomStartCommand,watchPaths,dockerfileLocation,dockerfileTargetBuild,publishDirectory,installCommand,buildCommand,startCommand,customNginxConfiguration,dockerfile,dockerRegistryImageName,dockerRegistryImageTag,portsExposes,portsMappings,customNetworkAliases,customDockerRunOptions,httpBasicAuthUsername,httpBasicAuthPassword,preDeploymentCommand,preDeploymentCommandContainer,postDeploymentCommand,postDeploymentCommandContainer,isContainerLabelReadonlyEnabled,isContainerLabelEscapeEnabled,customLabels" />
|
||||
<div class="application-settings-grid flex flex-col gap-6">
|
||||
<x-application.settings-section id="application-details-section" title="Application details" helper="Name the application and choose the build strategy Coolify should use to deploy it." class="application-details-card">
|
||||
@if ($buildPack === 'dockercompose')
|
||||
@if ($buildPack === 'dockercompose' && filled($dockerComposeRaw))
|
||||
<x-slot:actions>
|
||||
<x-forms.button canGate="update" :canResource="$application" wire:target='initLoadingCompose'
|
||||
x-on:click="$wire.dispatch('loadCompose', false)">
|
||||
{{ $application->docker_compose_raw ? 'Reload compose' : 'Load compose' }}
|
||||
Reload compose
|
||||
</x-forms.button>
|
||||
</x-slot:actions>
|
||||
@endif
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<nav wire:poll.10000ms="checkStatus" class="w-full max-w-none">
|
||||
<nav wire:poll.10000ms="checkStatus" class="w-full max-w-none" x-data
|
||||
x-init="@js($loadComposeOnOpen) && $wire.loadComposeFile(true)">
|
||||
<div>
|
||||
@if ($application->build_pack === 'nixpacks')
|
||||
<x-callout type="warning" title="Nixpacks is deprecated" class="mb-4">
|
||||
@@ -31,7 +32,20 @@
|
||||
</div>
|
||||
|
||||
<div class="shrink-0">
|
||||
@if (!($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw)))
|
||||
@if ($this->needsComposeFile())
|
||||
<div class="flex items-center gap-1">
|
||||
@can('update', $application)
|
||||
<x-forms.button wire:click="loadComposeFile">Load compose</x-forms.button>
|
||||
@endcan
|
||||
@can('deploy', $application)
|
||||
<x-forms.button id="application-mobile-actions" disabled default-class="split-action-main rounded-md!"
|
||||
tooltip="Load the Compose file before you deploy.">
|
||||
<x-reicon name="play-circle" class="size-3.5" />
|
||||
Deploy
|
||||
</x-forms.button>
|
||||
@endcan
|
||||
</div>
|
||||
@else
|
||||
@can('deploy', $application)
|
||||
<x-split-action id="application-mobile-actions">
|
||||
@if (str($application->status)->startsWith('exited'))
|
||||
@@ -134,8 +148,17 @@
|
||||
@if ($this->runningDeploymentUrl)
|
||||
<x-deploying-indicator :href="$this->runningDeploymentUrl" class="mr-1" />
|
||||
@endif
|
||||
@if ($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw))
|
||||
<span class="px-2 text-[13px] text-neutral-500 dark:text-fg-dim">Load a Compose file to deploy.</span>
|
||||
@if ($this->needsComposeFile())
|
||||
@can('update', $application)
|
||||
<x-forms.button wire:click="loadComposeFile">Load compose</x-forms.button>
|
||||
@endcan
|
||||
@can('deploy', $application)
|
||||
<x-forms.button id="application-desktop-actions" disabled default-class="split-action-main rounded-md!"
|
||||
tooltip="Load the Compose file before you deploy.">
|
||||
<x-reicon name="play-circle" class="size-3.5" />
|
||||
Deploy
|
||||
</x-forms.button>
|
||||
@endcan
|
||||
@else
|
||||
<div class="resource-heading-menus shrink-0">
|
||||
<x-applications.links :application="$application" />
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\Heading;
|
||||
use App\Models\Application;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->admin = User::factory()->create();
|
||||
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
|
||||
$this->member = User::factory()->create();
|
||||
$this->member->teams()->attach($this->team, ['role' => 'member']);
|
||||
|
||||
$keyId = DB::table('private_keys')->insertGetId([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'Test Key',
|
||||
'private_key' => 'test-key',
|
||||
'team_id' => $this->team->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $keyId]);
|
||||
$destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate(
|
||||
['server_id' => $server->id, 'network' => 'coolify'],
|
||||
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
|
||||
));
|
||||
$project = Project::create(['uuid' => (string) Str::uuid(), 'name' => 'Test Project', 'team_id' => $this->team->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $project->environments()->first()->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => null,
|
||||
'status' => 'exited',
|
||||
]);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
test('an admin opening a compose application without a compose file loads it automatically', function () {
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Livewire::test(Heading::class, ['application' => $this->application])
|
||||
->assertSet('loadComposeOnOpen', true)
|
||||
->assertSee('Load compose')
|
||||
->assertSee('Load the Compose file before you deploy.')
|
||||
->assertDontSee('Load a Compose file to deploy.');
|
||||
});
|
||||
|
||||
test('a compose application that already has a compose file does not load it on open', function () {
|
||||
$this->actingAs($this->admin);
|
||||
$this->application->update(['docker_compose_raw' => "services:\n web:\n image: nginx\n"]);
|
||||
|
||||
Livewire::test(Heading::class, ['application' => $this->application])
|
||||
->assertSet('loadComposeOnOpen', false)
|
||||
->assertDontSee('Load compose');
|
||||
});
|
||||
|
||||
test('the automatic load is skipped when the compose file is already loaded', function () {
|
||||
$this->actingAs($this->admin);
|
||||
$component = Livewire::test(Heading::class, ['application' => $this->application]);
|
||||
|
||||
$this->application->update(['docker_compose_raw' => "services:\n web:\n image: nginx\n"]);
|
||||
|
||||
$component->call('loadComposeFile', true)
|
||||
->assertNotDispatched('composeFileLoaded')
|
||||
->assertNotDispatched('error');
|
||||
});
|
||||
|
||||
test('a member cannot load the compose file and does not load it on open', function () {
|
||||
$this->actingAs($this->member);
|
||||
|
||||
Livewire::test(Heading::class, ['application' => $this->application])
|
||||
->assertSet('loadComposeOnOpen', false)
|
||||
->assertDontSee('Load compose')
|
||||
->call('loadComposeFile')
|
||||
->assertDispatched('error')
|
||||
->assertNotDispatched('composeFileLoaded');
|
||||
|
||||
expect($this->application->refresh()->docker_compose_raw)->toBeNull();
|
||||
});
|
||||
Reference in New Issue
Block a user