fix(storage): split S3 endpoint input and persist stopped status

Persist exited status across application, database, service, and preview stop paths, and reuse the split host/port/path input for S3 endpoints with linked validation feedback.
This commit is contained in:
Andras Bacsai
2026-08-15 11:46:49 +02:00
parent ec8a24d178
commit 2677e47138
12 changed files with 132 additions and 30 deletions
+4 -6
View File
@@ -28,7 +28,7 @@ class StopApplication
if ($server->isSwarm()) {
instant_remote_process(["docker stack rm {$application->uuid}"], $server);
return;
continue;
}
$containers = $previewDeployments
@@ -57,17 +57,15 @@ class StopApplication
}
}
$status = ['status' => 'exited'];
if ($resetRestartCount) {
$application->update([
$status = array_merge($status, [
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
} else {
$application->update([
'status' => 'exited',
]);
}
$application->update($status);
ServiceStatusChanged::dispatch($application->environment->project->team->id);
}
+1
View File
@@ -30,6 +30,7 @@ class StopDatabase
// Reset restart tracking when database is manually stopped
$database->update([
'status' => 'exited',
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
+3
View File
@@ -49,6 +49,9 @@ class StopService
$this->stopContainersInParallel($containersToStop, $server);
}
$applications->each->update(['status' => 'exited']);
$dbs->each->update(['status' => 'exited']);
if ($deleteConnectedNetworks) {
$service->deleteConnectedNetworks();
}
@@ -2,6 +2,7 @@
namespace App\Actions\Service;
use App\Events\ServiceStatusChanged;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Lorisleiva\Actions\Concerns\AsAction;
@@ -21,5 +22,8 @@ class StopServiceApplication
instant_remote_process([
"docker stop {$containerName}",
], $server);
$serviceApplication->update(['status' => 'exited']);
ServiceStatusChanged::dispatch($service->environment->project->team->id);
}
}
@@ -3,6 +3,7 @@
namespace App\Livewire\Project\Application;
use App\Actions\Docker\GetContainersStatus;
use App\Events\ServiceStatusChanged;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
@@ -373,6 +374,11 @@ class Previews extends Component
$this->stopContainers($containers, $server);
}
ApplicationPreview::where('application_id', $this->application->id)
->where('pull_request_id', $pull_request_id)
->update(['status' => 'exited']);
ServiceStatusChanged::dispatch($this->application->environment->project->team->id);
GetContainersStatus::run($server);
$this->application->refresh();
$this->dispatch('containerStatusUpdated');
+1 -1
View File
@@ -26,7 +26,7 @@ class Create extends Component
public string $bucket;
public string $endpoint;
public string $endpoint = '';
public S3Storage $storage;
@@ -3,6 +3,8 @@
'wire' => true,
'value' => '',
'errorId' => null,
'hostLabel' => 'Domain',
'hostPlaceholder' => 'app.example.com',
])
<div class="grid gap-4 sm:grid-cols-[8rem_minmax(0,1fr)_8rem]" x-data="{
@@ -51,13 +53,24 @@
<div class="min-w-0">
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label for="{{ $id }}" class="mb-0! flex items-center gap-1.5 leading-4">
Domain <x-highlighted text="*" />
{{ $hostLabel }} <x-highlighted text="*" />
</label>
</div>
<input id="{{ $id }}" type="text" class="input" x-model="host" placeholder="app.example.com"
<input id="{{ $id }}" type="text" class="input" x-model="host" placeholder="{{ $hostPlaceholder }}"
autocomplete="off" required />
@error($errorId ?? $id)
<p class="mt-1 text-[12px] text-red-500">{{ $message }}</p>
@php
preg_match('/(https?:\/\/\S+)$/', $message, $validationLinkMatches);
$validationLink = $validationLinkMatches[1] ?? null;
@endphp
<p class="mt-1 text-[12px] text-red-500">
@if ($validationLink)
{{ str($message)->beforeLast($validationLink)->trim() }}
<a class="font-medium underline" href="{{ $validationLink }}">Set them here.</a>
@else
{{ $message }}
@endif
</p>
@enderror
</div>
@@ -7,18 +7,8 @@
<x-forms.input required label="Name" id="name" />
<x-forms.input label="Description" id="description" />
</div>
<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 }));
}
" />
<x-forms.domain-input id="endpoint" host-label="Host"
host-placeholder="minio.internal or 192.168.1.50" />
<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."
@@ -16,8 +16,12 @@
<x-forms.input canGate="update" :canResource="$storage" label="Name" id="name" />
<x-forms.input canGate="update" :canResource="$storage" label="Description" id="description" />
<div class="lg:col-span-2">
<x-forms.input canGate="update" :canResource="$storage" required label="Endpoint"
id="endpoint" />
@can('update', $storage)
<x-forms.domain-input id="endpoint" host-label="Host"
host-placeholder="minio.internal or 192.168.1.50" />
@else
<x-forms.input label="Endpoint" :value="$endpoint" disabled />
@endcan
</div>
<x-forms.input canGate="update" :canResource="$storage" required label="Bucket" id="bucket" />
<x-forms.input canGate="update" :canResource="$storage" required label="Region" id="region" />
+39
View File
@@ -0,0 +1,39 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
use Illuminate\Support\MessageBag;
use Illuminate\Support\ViewErrorBag;
it('renders configurable host copy for S3 endpoints', function () {
View::share('errors', new ViewErrorBag);
$html = Blade::render(<<<'BLADE'
<x-forms.domain-input id="endpoint" :wire="false" value="http://192.168.1.50:9000/s3"
host-label="Host" host-placeholder="minio.internal or 192.168.1.50" />
BLADE);
expect($html)
->toContain('Host')
->toContain('minio.internal or 192.168.1.50')
->toContain("scheme: 'https'")
->toContain("host: ''")
->toContain("port: ''")
->toContain("path: ''");
});
it('keeps validation errors attached to the composed endpoint', function () {
$settingsUrl = route('settings.advanced').'#endpoint-section';
$errors = new ViewErrorBag;
$errors->put('default', new MessageBag([
'endpoint' => "The endpoint is invalid. Configure allowed internal targets: {$settingsUrl}",
]));
View::share('errors', $errors);
$html = Blade::render('<x-forms.domain-input id="endpoint" :wire="false" />');
expect($html)
->toContain('The endpoint is invalid.')
->toContain('href="'.$settingsUrl.'"')
->toContain('Set them here.');
});
@@ -5,14 +5,18 @@ 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'));
it('uses the shared split URL input without a Livewire blur request', function () {
$createView = file_get_contents(resource_path('views/livewire/storage/create.blade.php'));
$editView = file_get_contents(resource_path('views/livewire/storage/form.blade.php'));
expect($view)
expect($createView)
->not->toContain('wire:model.blur="endpoint"')
->toContain('x-on:blur')
->toContain('const hasScheme')
->toContain('dispatchEvent');
->toContain('<x-forms.domain-input id="endpoint"')
->toContain('host-label="Host"')
->toContain('host-placeholder="minio.internal or 192.168.1.50"')
->and($editView)
->toContain('<x-forms.domain-input id="endpoint"')
->toContain('@can(\'update\', $storage)');
});
it('normalizes endpoints again on the backend', function (string $endpoint, string $expected) {
@@ -0,0 +1,40 @@
<?php
it('persists exited status when stopping standalone databases', function () {
$action = file_get_contents(__DIR__.'/../../app/Actions/Database/StopDatabase.php');
expect($action)->toContain("'status' => 'exited'");
});
it('persists exited status for every full application stop path', function () {
$action = file_get_contents(__DIR__.'/../../app/Actions/Application/StopApplication.php');
expect($action)
->toContain("\$status = ['status' => 'exited'];")
->toContain('$application->update($status);')
->not->toMatch('/docker stack rm .*?return;/s');
});
it('persists exited status for all children when stopping a service', function () {
$action = file_get_contents(__DIR__.'/../../app/Actions/Service/StopService.php');
expect($action)
->toContain("\$applications->each->update(['status' => 'exited']);")
->toContain("\$dbs->each->update(['status' => 'exited']);");
});
it('persists exited status when stopping an individual service resource', function () {
$action = file_get_contents(__DIR__.'/../../app/Actions/Service/StopServiceApplication.php');
expect($action)
->toContain("\$serviceApplication->update(['status' => 'exited']);")
->toContain('ServiceStatusChanged::dispatch($service->environment->project->team->id);');
});
it('persists exited status when stopping a preview deployment', function () {
$component = file_get_contents(__DIR__.'/../../app/Livewire/Project/Application/Previews.php');
expect($component)
->toContain("->update(['status' => 'exited']);")
->toContain('ServiceStatusChanged::dispatch($this->application->environment->project->team->id);');
});