fix(ui): scope env refreshes, block duplicate container names, use a confirm modal

- refreshEnvs is now sent only to the environment variable list, and
  rows no longer listen to it, so adding or removing variables cannot
  queue an update for a removed row ("Snapshot missing"). Locking a row
  refreshes that row directly; the compose editor gets its own event.
- saveCustomName() no longer saves a custom container name that another
  application on the server already uses; it restores the stored value.
- Cancelling a GitHub runner uses the Coolify confirmation modal instead
  of a native browser dialog.
This commit is contained in:
Andras Bacsai
2026-10-01 20:31:00 +02:00
parent 3379f85c0a
commit 7a4709da69
9 changed files with 317 additions and 13 deletions
@@ -245,7 +245,6 @@ class Advanced extends Component
return;
}
$customInternalName = $this->customInternalName;
$server = $this->application->destination->server;
$allApplications = $server->applications();
@@ -253,9 +252,8 @@ class Advanced extends Component
return $application->id !== $this->application->id && $application->settings->custom_internal_name === $this->customInternalName;
});
if ($foundSameInternalName->isNotEmpty()) {
$this->customInternalName = $this->application->settings->custom_internal_name;
$this->dispatch('error', 'This custom container name is already in use by another application on this server.');
$this->customInternalName = $customInternalName;
$this->syncData(true);
return;
}
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Project\Application;
use App\Enums\StaticImageTypes;
use App\Jobs\ApplicationDeploymentJob;
use App\Livewire\Project\Service\Storage;
use App\Livewire\Project\Shared\EnvironmentVariable\All;
use App\Models\Application;
use App\Rules\ValidGitBranch;
use App\Services\Dns\ManagedDnsRecordCleanup;
@@ -557,7 +558,7 @@ class General extends Component
$showToast && $this->dispatch('success', 'Docker compose file loaded.');
$this->dispatch('compose_loaded');
$this->dispatch('storageCountsChanged')->to(Storage::class);
$this->dispatch('refreshEnvs');
$this->dispatch('refreshEnvs')->to(All::class);
} catch (\Throwable $e) {
// Refresh model to get restored values from Application::loadComposeFile
$this->application->refresh();
@@ -4,6 +4,7 @@ namespace App\Livewire\Project\Database\Redis;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Livewire\Project\Shared\EnvironmentVariable\All;
use App\Models\Server;
use App\Models\StandaloneRedis;
use App\Support\ValidationPatterns;
@@ -202,7 +203,7 @@ class General extends Component
} catch (Exception $e) {
return handleError($e, $this);
} finally {
$this->dispatch('refreshEnvs');
$this->dispatch('refreshEnvs')->to(All::class);
}
}
+3 -1
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Service;
use App\Livewire\Project\Shared\EnvironmentVariable\All;
use App\Models\Service;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -179,7 +180,8 @@ class StackForm extends Component
$this->service->refresh();
$this->service->saveComposeConfigs();
$this->dispatch('refreshEnvs');
$this->dispatch('refreshEnvs')->to(EditCompose::class);
$this->dispatch('refreshEnvs')->to(All::class);
$this->dispatch('refreshServices');
$notify && $this->dispatch('success', 'Service saved.');
} catch (\Throwable $e) {
@@ -93,7 +93,6 @@ class Show extends Component
public array $problematicVariables = [];
protected $listeners = [
'refreshEnvs' => 'refresh',
'refresh',
'compose_loaded' => '$refresh',
];
@@ -296,8 +295,8 @@ class Show extends Component
}
$this->serialize();
$this->env->save();
$this->checkEnvs();
$this->dispatch('refreshEnvs');
$this->refresh();
$this->dispatch('refreshEnvs')->to(All::class);
}
public function instantSave()
@@ -40,10 +40,13 @@
@endif
@if ($execution->isActive())
@can('update', $server)
<x-forms.button wire:click="cancel({{ $execution->id }})"
wire:confirm="Cancel this runner? A running job fails.">
Cancel
</x-forms.button>
<x-modal-confirmation title="Cancel this runner?" buttonTitle="Cancel"
submitAction="cancel({{ $execution->id }})" :actions="[
'The runner will be stopped and removed.',
'A running workflow job fails.',
]"
:confirmWithText="false" :confirmWithPassword="false"
step2ButtonText="Cancel Runner" isErrorButton />
@endcan
@endif
</div>
@@ -0,0 +1,188 @@
<?php
use App\Livewire\Project\Application\General as ApplicationGeneral;
use App\Livewire\Project\Database\Redis\General as RedisGeneral;
use App\Livewire\Project\Service\EditCompose;
use App\Livewire\Project\Service\StackForm;
use App\Livewire\Project\Shared\EnvironmentVariable\All;
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Livewire\Exceptions\EventHandlerDoesNotExist;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
config(['constants.ssh.mux_enabled' => false]);
Server::flushIdentityMap();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
]);
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
});
/**
* A `refreshEnvs` dispatch without a target reaches every environment variable row
* as well as the list, which races row removal ("Snapshot missing on Livewire component").
*/
function assertNoPageWideRefreshEnvs(Testable $component): void
{
$pageWide = collect(data_get($component->effects, 'dispatches'))
->filter(fn (array $dispatch): bool => $dispatch['name'] === 'refreshEnvs'
&& ! isset($dispatch['to'])
&& empty($dispatch['self']));
expect($pageWide)->toBeEmpty();
}
test('reloading the compose file refreshes only the environment variable list', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'git_repository' => 'https://github.com/coollabsio/compose-app',
'git_branch' => 'main',
'base_directory' => '/',
'docker_compose_location' => '/docker-compose.yml',
]);
Process::fake(function ($process) {
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
if (str_contains($command, 'git --version')) {
return Process::result(output: 'git version 2.43.0');
}
if (str_contains($command, 'head -c')) {
return Process::result(output: "services:\n web:\n image: nginx:alpine\n");
}
return Process::result(output: '');
});
$component = Livewire::test(ApplicationGeneral::class, ['application' => $this->application->fresh()])
->call('loadComposeFile')
->assertDispatched('success', 'Docker compose file loaded.')
->assertDispatchedTo(All::class, 'refreshEnvs');
assertNoPageWideRefreshEnvs($component);
});
test('saving redis credentials refreshes only the environment variable list', function () {
$database = create_standalone_redis($this->environment->id, $this->destination, ['redis_password' => 'old-password'])->fresh();
$component = Livewire::test(RedisGeneral::class, ['database' => $database])
->set('redisPassword', 'new-password')
->call('submit')
->assertDispatched('success', 'Database updated.')
->assertDispatchedTo(All::class, 'refreshEnvs');
assertNoPageWideRefreshEnvs($component);
expect($database->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->firstOrFail()->value)
->toBe('new-password');
});
test('saving a service refreshes the compose editor and the environment variable list only', function () {
Process::fake();
$service = Service::factory()->create([
'name' => 'stack-service',
'connect_to_docker_network' => false,
'docker_compose_raw' => "services:\n app:\n image: nginx:alpine\n",
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$component = Livewire::test(StackForm::class, ['service' => $service])
->call('submit')
->assertDispatched('success', 'Service saved.')
->assertDispatchedTo(All::class, 'refreshEnvs')
->assertDispatchedTo(EditCompose::class, 'refreshEnvs');
assertNoPageWideRefreshEnvs($component);
});
test('locking a variable updates the row itself and refreshes only the list', function () {
$env = EnvironmentVariable::create([
'key' => 'LOCK_ME',
'value' => 'secret',
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
$component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application'])
->call('lock')
->assertSet('isLocked', true)
->assertSet('is_shown_once', true)
->assertSet('valuesLoaded', false)
->assertDispatchedTo(All::class, 'refreshEnvs');
assertNoPageWideRefreshEnvs($component);
expect($env->fresh()->is_shown_once)->toBeTruthy();
});
test('environment variable rows do not handle the list-wide refreshEnvs event', function () {
$env = EnvironmentVariable::create([
'key' => 'ROW_KEY',
'value' => 'value',
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
$row = Livewire::test(Show::class, ['env' => $env, 'type' => 'application']);
expect(fn () => $row->dispatch('refreshEnvs'))->toThrow(EventHandlerDoesNotExist::class);
});
test('refreshing the list renders added rows with current data and drops deleted rows', function () {
$removed = EnvironmentVariable::create([
'key' => 'REMOVED_KEY',
'value' => 'value',
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
$component = Livewire::test(All::class, ['resource' => $this->application])
->call('loadEnvironmentVariables')
->assertSee('REMOVED_KEY');
$removed->delete();
EnvironmentVariable::create([
'key' => 'ADDED_KEY',
'value' => 'value',
'comment' => 'fresh comment',
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
$component->dispatch('refreshEnvs')
->assertSee('ADDED_KEY')
->assertSee('fresh comment')
->assertDontSee('REMOVED_KEY');
});
@@ -148,3 +148,37 @@ it('rejects a container name prefix already used on the server', function () {
expect($application->settings()->first()->custom_container_name_prefix)->toBeNull();
});
it('does not save a custom container name already used on the server', function () {
$application = createApplicationForContainerNamingTest();
$application->settings->update(['custom_internal_name' => 'original-name']);
Application::factory()->create([
'environment_id' => $application->environment_id,
'destination_id' => $application->destination_id,
'destination_type' => $application->destination_type,
])->settings->update(['custom_internal_name' => 'taken-name']);
$application = $application->fresh(['environment.project', 'settings', 'destination']);
Livewire::test(Advanced::class, ['application' => $application])
->set('customInternalName', 'taken-name')
->call('saveCustomName')
->assertDispatched('error', 'This custom container name is already in use by another application on this server.')
->assertNotDispatched('success')
->assertSet('customInternalName', 'original-name');
expect($application->settings()->first()->custom_internal_name)->toBe('original-name');
});
it('saves a unique custom container name', function () {
$application = createApplicationForContainerNamingTest();
$application = $application->fresh(['environment.project', 'settings', 'destination']);
Livewire::test(Advanced::class, ['application' => $application])
->set('customInternalName', 'Unique Name')
->call('saveCustomName')
->assertDispatched('success', 'Custom name saved.')
->assertSet('customInternalName', 'unique-name');
expect($application->settings()->first()->custom_internal_name)->toBe('unique-name');
});
@@ -0,0 +1,78 @@
<?php
use App\Models\Application;
use App\Models\EnvironmentVariable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.maintenance.store', 'array');
$this->stack = seedBrowserResourceStack();
$this->application = createBrowserApplication($this->stack, [
'uuid' => 'app-browser-env-refresh',
'name' => 'Env Refresh App',
'build_pack' => 'dockerfile',
'dockerfile' => "FROM nginx:alpine\n",
]);
foreach (['ALPHA_KEY', 'BRAVO_KEY', 'CHARLIE_KEY', 'DELTA_KEY'] as $key) {
EnvironmentVariable::create([
'key' => $key,
'value' => strtolower($key),
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
}
});
it('locks and deletes environment variables repeatedly without Livewire errors', function () {
loginAndSkipBoarding();
$url = applicationConfigurationUrl(
$this->stack['project'],
$this->stack['environment'],
$this->application
).'/environment-variables';
$page = visit($url);
$page->click('Accept and close')
->assertSee('ALPHA_KEY')
->assertSee('DELTA_KEY');
// Collect uncaught errors (e.g. "Snapshot missing on Livewire component") raised by the actions below.
$page->script(<<<'JS'
(() => {
window.__envListErrors = [];
window.addEventListener('error', (event) => window.__envListErrors.push(String(event.message)));
window.addEventListener('unhandledrejection', (event) => window.__envListErrors.push(String(event.reason)));
const consoleError = console.error;
console.error = (...args) => {
window.__envListErrors.push(args.map(String).join(' '));
consoleError(...args);
};
})()
JS);
$page->click('button[title="ALPHA_KEY"]')
->click('[data-environment-variable-update-actions] button:has-text("Lock"):visible')
->wait(1);
$page->script('window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))');
$page->wait(0.5);
foreach (['BRAVO_KEY', 'CHARLIE_KEY', 'DELTA_KEY'] as $key) {
$page->click("button[title=\"{$key}\"]")
->click('[data-environment-variable-delete-action] button:visible')
->fill('[x-model="userConfirmationText"]:visible', $key)
->click('button:has([x-text="step2ButtonText"]):visible')
->assertSee('Environment variable deleted successfully.')
->assertDontSee($key);
}
$page->assertSee('ALPHA_KEY')
->screenshot(filename: 'environment-variable-list-refresh');
expect($page->script('window.__envListErrors'))->toBe([])
->and($this->application->environment_variables()->pluck('key')->all())->toBe(['ALPHA_KEY'])
->and($this->application->environment_variables()->where('key', 'ALPHA_KEY')->value('is_shown_once'))->toBeTruthy();
});