fix(ui): normalize S3 endpoints and remove duplicate config warnings

Normalize endpoints on blur and submit, link internal-target errors to settings, and target the S3 submit loading state.
This commit is contained in:
Andras Bacsai
2026-08-14 18:48:34 +02:00
parent a257224bb7
commit b3789d4a36
9 changed files with 127 additions and 31 deletions
+36 -24
View File
@@ -71,34 +71,12 @@ class Create extends Component
'endpoint' => 'Endpoint',
];
public function updatedEndpoint($value)
{
try {
if (empty($value)) {
return;
}
if (str($value)->contains('digitaloceanspaces.com')) {
$uri = Uri::of($value);
$host = $uri->host();
if (preg_match('/^(.+)\.([^.]+\.digitaloceanspaces\.com)$/', $host, $matches)) {
$host = $matches[2];
$value = "https://{$host}";
}
}
} finally {
if (! str($value)->startsWith('https://') && ! str($value)->startsWith('http://')) {
$value = 'https://'.$value;
}
$this->endpoint = $value;
}
}
public function submit()
{
try {
$this->authorize('create', S3Storage::class);
$this->endpoint = $this->normalizeEndpoint($this->endpoint);
$this->validate();
$this->storage = new S3Storage;
$this->storage->name = $this->name;
@@ -118,8 +96,42 @@ class Create extends Component
return redirectRoute($this, 'storage.show', [$this->storage->uuid]);
} catch (\Throwable $e) {
$this->dispatch('error', 'Failed to create storage.', $e->getMessage());
$this->dispatch('error', 'Failed to create storage.', $this->connectionErrorDescription($e));
// return handleError($e, $this);
}
}
private function connectionErrorDescription(\Throwable $exception): string
{
$settingsUrl = route('settings.advanced').'#endpoint-section';
$description = e($exception->getMessage());
if (! str_contains($exception->getMessage(), $settingsUrl)) {
return $description;
}
$link = '<a class="font-medium underline" href="'.e($settingsUrl).'">Set them here.</a>';
return str_replace(e($settingsUrl), $link, $description);
}
private function normalizeEndpoint(string $endpoint): string
{
$endpoint = trim($endpoint);
$hasScheme = preg_match('/^(?:https?:|[a-z][a-z0-9+.-]*:\/\/)/i', $endpoint) === 1;
if (! $hasScheme) {
$endpoint = 'https://'.$endpoint;
}
if (str($endpoint)->contains('digitaloceanspaces.com')) {
$host = Uri::of($endpoint)->host();
if (preg_match('/^(.+)\.([^.]+\.digitaloceanspaces\.com)$/', $host, $matches)) {
return "https://{$matches[2]}";
}
}
return $endpoint;
}
}
@@ -53,7 +53,6 @@
<x-slot:title>
{{ data_get_str($service, 'name')->limit(10) }} > Backups | Coolify
</x-slot>
<livewire:project.shared.configuration-checker :resource="$service" />
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="request()->query()"
wire:key="service-heading-volume-backup-index" />
@@ -7,10 +7,8 @@
<livewire:project.shared.configuration-checker :resource="$resource" />
<livewire:project.application.heading :application="$resource" wire:key="application-heading-command" />
@elseif ($type === 'database')
<livewire:project.shared.configuration-checker :resource="$resource" />
<livewire:project.database.heading :database="$resource" />
@elseif ($type === 'service')
<livewire:project.shared.configuration-checker :resource="$resource" />
<livewire:project.service.heading :service="$resource" :parameters="$parameters" title="Terminal" />
@else
<livewire:server.navbar :server="$servers->first()" />
@@ -3,9 +3,8 @@
{{ data_get_str($resource, 'name')->limit(10) }} > Logs | Coolify
</x-slot>
<livewire:project.shared.configuration-checker :resource="$resource" />
@if ($type === 'application')
<livewire:project.shared.configuration-checker :resource="$resource" />
<livewire:project.application.heading :application="$resource" wire:key="application-heading-logs" />
@elseif ($type === 'database')
<livewire:project.database.heading :database="$resource" />
@@ -7,7 +7,18 @@
<x-forms.input required label="Name" id="name" />
<x-forms.input label="Description" id="description" />
</div>
<x-forms.input required type="url" label="Endpoint" wire:model.blur="endpoint" />
<x-forms.input required type="url" label="Endpoint" id="endpoint"
x-on:blur="
let value = $el.value.trim();
const hasScheme = /^https?:/i.test(value) || /^[a-z][a-z0-9+.-]*:\/\//i.test(value);
if (value && !hasScheme) {
value = `https://${value}`;
}
if ($el.value !== value) {
$el.value = value;
$el.dispatchEvent(new Event('input', { bubbles: true }));
}
" />
<div class="flex gap-2">
<x-forms.input required label="Bucket" id="bucket" />
<x-forms.input required helper="Region only required for AWS. Leave it as-is for other providers."
@@ -18,7 +29,7 @@
<x-forms.input required type="password" label="Secret Key" id="secret" />
</div>
<x-forms.button class="mt-4" type="submit">
<x-forms.button class="mt-4" type="submit" wire:target="submit">
Validate Connection & Continue
</x-forms.button>
</form>
+6
View File
@@ -30,6 +30,12 @@ it('uses an explicit wire target for submit buttons without wire click', functio
->toContain('Validate and add');
});
it('targets the S3 connection validation submit action', function () {
$view = file_get_contents(resource_path('views/livewire/storage/create.blade.php'));
expect($view)->toContain('<x-forms.button class="mt-4" type="submit" wire:target="submit">');
});
it('keeps method arguments when deriving the loading target', function () {
$html = Blade::render(
'<x-forms.button wire:click="setPrivateKey(42)">Use this key</x-forms.button>'
@@ -334,6 +334,32 @@ it('renders configuration warnings as navbar popovers instead of floating notifi
->toContain("\$dispatch('open-configuration-diff')");
});
it('renders only one configuration warning on database runtime logs', function () {
$logs = file_get_contents(resource_path('views/livewire/project/shared/logs.blade.php'));
$databaseHeading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
expect($logs)
->not->toContain(" <livewire:project.shared.configuration-checker :resource=\"\$resource\" />\n\n @if (\$type === 'application')")
->and($databaseHeading)
->toContain('<livewire:project.shared.configuration-checker :resource="$database" />');
});
it('renders only one configuration warning on database and service terminal pages', function () {
$terminal = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
expect($terminal)
->not->toContain("@elseif (\$type === 'database')\n <livewire:project.shared.configuration-checker :resource=\"\$resource\" />")
->not->toContain("@elseif (\$type === 'service')\n <livewire:project.shared.configuration-checker :resource=\"\$resource\" />");
});
it('renders only one configuration warning on the service volume backups page', function () {
$volumeBackups = file_get_contents(resource_path('views/livewire/project/service/volume-backup/index.blade.php'));
expect($volumeBackups)
->not->toContain('<livewire:project.shared.configuration-checker :resource="$service" />')
->toContain('<livewire:project.service.heading :service="$service"');
});
it('moves application backups from the top tabs into the settings sidebar', function () {
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$configuration = file_get_contents(resource_path('views/livewire/project/application/configuration.blade.php'));
@@ -0,0 +1,16 @@
<?php
use App\Livewire\Storage\Create;
it('formats the internal target settings route as a clickable link', function () {
$settingsUrl = route('settings.advanced').'#endpoint-section';
$exception = new RuntimeException("Private target. Configure allowed internal targets: {$settingsUrl}");
$method = new ReflectionMethod(Create::class, 'connectionErrorDescription');
$description = $method->invoke(new Create, $exception);
expect($description)
->toContain('href="'.$settingsUrl.'"')
->toContain('Set them here.')
->not->toContain('targets: '.$settingsUrl);
});
@@ -0,0 +1,29 @@
<?php
use App\Livewire\Storage\Create;
use Tests\TestCase;
uses(TestCase::class);
it('normalizes S3 endpoints without a Livewire blur request', function () {
$view = file_get_contents(resource_path('views/livewire/storage/create.blade.php'));
expect($view)
->not->toContain('wire:model.blur="endpoint"')
->toContain('x-on:blur')
->toContain('const hasScheme')
->toContain('dispatchEvent');
});
it('normalizes endpoints again on the backend', function (string $endpoint, string $expected) {
$method = new ReflectionMethod(Create::class, 'normalizeEndpoint');
expect($method->invoke(new Create, $endpoint))->toBe($expected);
})->with([
'missing scheme' => ['s3.example.com', 'https://s3.example.com'],
'existing HTTP scheme' => ['http://192.168.1.50:9000', 'http://192.168.1.50:9000'],
'malformed HTTP scheme' => ['http:/192.168.1.50:9000', 'http:/192.168.1.50:9000'],
'hostname with port' => ['minio.internal:9000', 'https://minio.internal:9000'],
'unsupported scheme' => ['ftp://s3.example.com', 'ftp://s3.example.com'],
'DigitalOcean bucket endpoint' => ['https://bucket.nyc3.digitaloceanspaces.com', 'https://nyc3.digitaloceanspaces.com'],
]);