From 688134a994fa5e771d92030e34a324bc7ccac768 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:59:57 +0100 Subject: [PATCH] feat(backups): import backups from a dialog on the backups page Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + app/Livewire/Project/Database/Import.php | 57 +----- app/Livewire/Project/Database/ImportForm.php | 44 +---- app/Livewire/Project/Service/ImportBackup.php | 71 +++---- .../database/configuration-sidebar.blade.php | 3 +- .../service/configuration-sidebar.blade.php | 7 +- .../project/database/backup/index.blade.php | 4 + .../project/database/configuration.blade.php | 2 - .../project/database/heading.blade.php | 1 - .../project/database/import-form.blade.php | 141 ++++++++------ .../project/database/import.blade.php | 22 +-- .../service/database-backups.blade.php | 20 +- .../project/service/import-backup.blade.php | 58 +++--- .../livewire/project/service/index.blade.php | 178 +++++++++--------- .../service/volume-backup/index.blade.php | 4 + routes/web.php | 11 +- tests/Feature/ServiceResourceRoutingTest.php | 158 ++++------------ 17 files changed, 312 insertions(+), 473 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 727e31afc5..a4bec4ee52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Add an opt-in setting to also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. The applications API sets it with `is_auto_cancel_building_deployments_enabled` - Add `cancel_previous` (`none`, `queued`, or `building`) to `/api/v1/deploy` and the MCP `deploy` tool, so a deployment from the API or MCP can cancel the older webhook, API, and MCP deployments of the same application, pull request, and server. The default is `none`. Manual, restart, and rollback deployments are never cancelled +### Changed + +- Move **Import Backup** from the resource menu to an **Import backup** button on the **Backups** page of databases and services. The button opens the import in a dialog. Old **Import Backup** links open the **Backups** page ([#11818](https://github.com/coollabsio/coolify/discussions/11818)) + ### Fixed - Handle an existing Cloudflare DNS record with a different IP when Coolify adds the DNS record of a new domain automatically. Coolify now replaces the record when it created the record and no other resource uses it. Otherwise it opens the **Configure DNS** dialog, where you can replace the record, and the domain shows the conflict instead of "Not checked" diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index 6d6bf94867..e6898078af 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -9,6 +9,7 @@ use App\Models\StandaloneKeydb; use App\Models\StandaloneRedis; use App\Traits\ListensToTeamChannel; use Illuminate\Contracts\View\View; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Attributes\Locked; @@ -48,9 +49,8 @@ class Import extends Component ]; } - public function mount(): void + public function mount(Model $resource): void { - $resource = $this->resolveResourceFromRoute(); $this->authorize('view', $resource); $this->resourceId = $resource->id; @@ -72,57 +72,14 @@ class Import extends Component public function render(): View { - return view('livewire.project.database.import'); + return view('livewire.project.database.import', [ + 'resource' => $this->resolveStoredResource(), + ]); } - private function resolveResourceFromRoute(): object + private function resolveStoredResource(): Model { - $parameters = get_route_parameters(); - $teamId = data_get(Auth::user()?->currentTeam(), 'id'); - $databaseUuid = data_get($parameters, 'database_uuid'); - $stackServiceUuid = data_get($parameters, 'stack_service_uuid'); - - if ($databaseUuid) { - $resource = getResourceByUuid($databaseUuid, $teamId); - if ($resource) { - return $resource; - } - - abort(404); - } - - if ($stackServiceUuid) { - $project = currentTeam() - ->projects() - ->select('id', 'uuid', 'team_id') - ->where('uuid', data_get($parameters, 'project_uuid')) - ->firstOrFail(); - $environment = $project->environments() - ->select('id', 'uuid', 'name', 'project_id') - ->where('uuid', data_get($parameters, 'environment_uuid')) - ->firstOrFail(); - $service = $environment->services()->whereUuid(data_get($parameters, 'service_uuid'))->firstOrFail(); - $resource = $service->databases()->whereUuid($stackServiceUuid)->first(); - if ($resource) { - return $resource; - } - } - - abort(404); - } - - private function resolveStoredResource(): object - { - if ($this->resourceId === null || $this->resourceType === null) { - return $this->resolveResourceFromRoute(); - } - - $resource = $this->resourceType::find($this->resourceId); - if ($resource) { - return $resource; - } - - abort(404); + return $this->resourceType::find($this->resourceId) ?? abort(404); } private function isUnsupportedResource(object $resource): bool diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php index 1106972beb..5525e0646b 100644 --- a/app/Livewire/Project/Database/ImportForm.php +++ b/app/Livewire/Project/Database/ImportForm.php @@ -5,7 +5,6 @@ namespace App\Livewire\Project\Database; use App\Actions\Database\StartDatabaseImport; use App\Models\S3Storage; use App\Models\Server; -use App\Models\Service; use App\Models\ServiceDatabase; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; @@ -17,6 +16,7 @@ use App\Support\DatabaseImport\DatabaseImportCommandBuilder; use App\Support\DatabaseImport\DatabaseImportException; use App\Support\DatabaseImport\DatabaseImportSource; use App\Support\ValidationPatterns; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Storage; @@ -134,8 +134,6 @@ class ImportForm extends Component #[Locked] public string $resourceDbType = ''; - public array $parameters = []; - public array $containers = []; public bool $scpInProgress = false; @@ -245,10 +243,9 @@ class ImportForm extends Component $this->activityId = null; } - public function mount() + public function mount(Model $resource): void { - $this->parameters = get_route_parameters(); - $this->getContainers(); + $this->loadResource($resource); $this->loadAvailableS3Storages(); $this->initializeRestoreOptions(); } @@ -315,42 +312,9 @@ class ImportForm extends Component } } - public function getContainers() + private function loadResource(Model $resource): void { $this->containers = []; - $teamId = data_get(auth()->user()->currentTeam(), 'id'); - - // Try to find resource by route parameter - $databaseUuid = data_get($this->parameters, 'database_uuid'); - $stackServiceUuid = data_get($this->parameters, 'stack_service_uuid'); - - $resource = null; - if ($databaseUuid) { - // Standalone database route - $resource = getResourceByUuid($databaseUuid, $teamId); - if (is_null($resource)) { - abort(404); - } - } elseif ($stackServiceUuid) { - // ServiceDatabase route - look up the service database - $serviceUuid = data_get($this->parameters, 'service_uuid'); - $project = currentTeam() - ->projects() - ->select('id', 'uuid', 'team_id') - ->where('uuid', data_get($this->parameters, 'project_uuid')) - ->firstOrFail(); - $environment = $project->environments() - ->select('id', 'uuid', 'name', 'project_id') - ->where('uuid', data_get($this->parameters, 'environment_uuid')) - ->firstOrFail(); - $service = $environment->services()->whereUuid($serviceUuid)->firstOrFail(); - $resource = $service->databases()->whereUuid($stackServiceUuid)->first(); - if (is_null($resource)) { - abort(404); - } - } else { - abort(404); - } $this->authorize('view', $resource); diff --git a/app/Livewire/Project/Service/ImportBackup.php b/app/Livewire/Project/Service/ImportBackup.php index 29e8d9f359..46c8633578 100644 --- a/app/Livewire/Project/Service/ImportBackup.php +++ b/app/Livewire/Project/Service/ImportBackup.php @@ -7,74 +7,57 @@ use App\Models\ServiceDatabase; use Illuminate\Contracts\View\View; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; +use Livewire\Attributes\Computed; +use Livewire\Attributes\Locked; use Livewire\Component; +/** + * Content of the "Import backup" modal on the service backups page: pick a database, then restore into it. + */ class ImportBackup extends Component { use AuthorizesRequests; + #[Locked] public Service $service; - public Collection $databases; - - public ?ServiceDatabase $selectedDatabase = null; - public string $selectedDatabaseUuid = ''; - public array $parameters; - - public function mount(): mixed + public function mount(Service $service): void { - $this->parameters = get_route_parameters(); - $project = currentTeam()->projects()->whereUuid($this->parameters['project_uuid'])->firstOrFail(); - $environment = $project->environments()->whereUuid($this->parameters['environment_uuid'])->firstOrFail(); - $this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail(); + $this->service = $service; $this->authorize('update', $this->service); - $this->databases = $this->service->databases - ->filter(fn (ServiceDatabase $database): bool => $this->supportsImport($database)) - ->values(); - - $databaseUuid = request()->route('stack_service_uuid'); - if ($databaseUuid) { - $selectedDatabase = $this->databases->firstWhere('uuid', $databaseUuid); - abort_unless($selectedDatabase instanceof ServiceDatabase, 404); - $this->authorize('update', $selectedDatabase); - $this->selectedDatabase = $selectedDatabase; - $this->selectedDatabaseUuid = $selectedDatabase->uuid; - - if (request()->routeIs('project.service.database.import')) { - return redirect()->route('project.service.import-backup.database', $this->parameters); - } - } elseif ($this->databases->count() === 1) { - return redirect()->route('project.service.import-backup.database', [ - ...$this->parameters, - 'stack_service_uuid' => $this->databases->first()->uuid, - ]); + if ($this->databases->count() === 1) { + $this->selectedDatabaseUuid = $this->databases->first()->uuid; } - - return null; } - public function updatedSelectedDatabaseUuid(): mixed + /** + * @return Collection + */ + #[Computed] + public function databases(): Collection + { + return $this->service->databases + ->filter(fn (ServiceDatabase $database): bool => str($database->databaseType())->contains(['mysql', 'mariadb', 'postgres', 'mongo'])) + ->values(); + } + + #[Computed] + public function selectedDatabase(): ?ServiceDatabase { $database = $this->databases->firstWhere('uuid', $this->selectedDatabaseUuid); - abort_unless($database instanceof ServiceDatabase, 404); + if (! $database instanceof ServiceDatabase) { + return null; + } $this->authorize('update', $database); - return redirect()->route('project.service.import-backup.database', [ - ...$this->parameters, - 'stack_service_uuid' => $database->uuid, - ]); + return $database; } public function render(): View { return view('livewire.project.service.import-backup'); } - - private function supportsImport(ServiceDatabase $database): bool - { - return str($database->databaseType())->contains(['mysql', 'mariadb', 'postgres', 'mongo']); - } } diff --git a/resources/views/components/database/configuration-sidebar.blade.php b/resources/views/components/database/configuration-sidebar.blade.php index 956a6d659a..b1ba1d8d74 100644 --- a/resources/views/components/database/configuration-sidebar.blade.php +++ b/resources/views/components/database/configuration-sidebar.blade.php @@ -12,7 +12,6 @@ ['label' => 'Environment Variables', 'route' => 'project.database.environment-variables', 'icon' => 'variables'], ['label' => 'Persistent Storage', 'route' => 'project.database.persistent-storage', 'icon' => 'storages'], ['label' => 'Backups', 'route' => 'project.database.backup.index', 'icon' => 'database', 'visible' => $database->isBackupSolutionAvailable()], - ['label' => 'Import Backup', 'route' => 'project.database.import-backup', 'icon' => 'upload', 'navigate' => false, 'visible' => auth()->user()?->can('update', $database)], ['label' => 'Servers', 'route' => 'project.database.servers', 'icon' => 'servers'], ['label' => 'Runtime Logs', 'route' => 'project.database.logs', 'icon' => 'unordered-list', 'navigate' => false], ['label' => 'Terminal', 'route' => 'project.database.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')], @@ -36,7 +35,7 @@ 'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Healthcheck'], 'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Files', 'Metrics'], 'Deploy' => ['Servers'], - 'Automation' => ['Webhooks', 'Backups', 'Import Backup'], + 'Automation' => ['Webhooks', 'Backups'], 'Operations' => ['Resource Operations', 'Resource Limits', 'Tags'], 'Danger Zone' => ['Danger Zone'], ]; diff --git a/resources/views/components/service/configuration-sidebar.blade.php b/resources/views/components/service/configuration-sidebar.blade.php index baa1e20bfa..04ac4d5cba 100644 --- a/resources/views/components/service/configuration-sidebar.blade.php +++ b/resources/views/components/service/configuration-sidebar.blade.php @@ -13,7 +13,6 @@ ['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable], ['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'], ['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'], - ['label' => 'Import Backup', 'route' => 'project.service.import-backup', 'icon' => 'upload', 'navigate' => false], ['label' => 'Runtime Logs', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false], ['label' => 'Terminal', 'route' => 'project.service.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')], ['label' => 'Files', 'route' => 'project.service.files', 'icon' => 'folder', 'visible' => auth()->user()?->can('canAccessTerminal')], @@ -30,15 +29,13 @@ || ($item['route'] === 'project.service.scheduled-tasks.show' && str($currentRoute)->startsWith('project.service.scheduled-tasks')) || ($item['route'] === 'project.service.volume-backups.index' - && str($currentRoute)->startsWith('project.service.volume-backups')) - || ($item['route'] === 'project.service.import-backup' - && str($currentRoute)->startsWith('project.service.import-backup')), + && str($currentRoute)->startsWith('project.service.volume-backups')), ]); $menuGroups = [ 'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'], 'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Files', 'Analytics'], - 'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups', 'Import Backup'], + 'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'], 'Operations' => ['Resource Operations', 'Tags'], 'Danger Zone' => ['Danger Zone'], ]; diff --git a/resources/views/livewire/project/database/backup/index.blade.php b/resources/views/livewire/project/database/backup/index.blade.php index da2140e061..07d96c67bc 100644 --- a/resources/views/livewire/project/database/backup/index.blade.php +++ b/resources/views/livewire/project/database/backup/index.blade.php @@ -12,6 +12,10 @@ description="Automate database backups and track the latest execution for each schedule."> @can('update', $database) + + + diff --git a/resources/views/livewire/project/database/configuration.blade.php b/resources/views/livewire/project/database/configuration.blade.php index 23778effa3..bce3d70f90 100644 --- a/resources/views/livewire/project/database/configuration.blade.php +++ b/resources/views/livewire/project/database/configuration.blade.php @@ -40,8 +40,6 @@ @elseif ($currentRoute === 'project.database.healthcheck') - @elseif ($currentRoute === 'project.database.import-backup') - @elseif ($currentRoute === 'project.database.webhooks') @elseif ($currentRoute === 'project.database.resource-limits') diff --git a/resources/views/livewire/project/database/heading.blade.php b/resources/views/livewire/project/database/heading.blade.php index ff7368861b..9555a40dfc 100644 --- a/resources/views/livewire/project/database/heading.blade.php +++ b/resources/views/livewire/project/database/heading.blade.php @@ -7,7 +7,6 @@ 'active' => request()->routeIs('project.database.configuration') || request()->routeIs('project.database.environment-variables') || request()->routeIs('project.database.servers') - || request()->routeIs('project.database.import-backup') || request()->routeIs('project.database.persistent-storage') || request()->routeIs('project.database.healthcheck') || request()->routeIs('project.database.webhooks') diff --git a/resources/views/livewire/project/database/import-form.blade.php b/resources/views/livewire/project/database/import-form.blade.php index 1e48e877f3..a9547086e4 100644 --- a/resources/views/livewire/project/database/import-form.blade.php +++ b/resources/views/livewire/project/database/import-form.blade.php @@ -7,67 +7,79 @@ s3FileSize: $wire.entangle('s3FileSize'), s3StorageId: $wire.entangle('s3StorageId'), s3Path: $wire.entangle('s3Path'), - restoreType: null -}"> - - @script - - @endscript -
+ if (window.Dropzone) { + attach(); + return; + } + let script = document.querySelector('script[data-dropzone]'); + if (! script) { + script = document.createElement('script'); + script.src = @js(URL::asset('js/dropzone.js')); + script.dataset.dropzone = ''; + document.head.appendChild(script); + } + script.addEventListener('load', attach, { once: true }); + }, +}"> +
Review the source and import command before continuing. Existing objects can cause the import to fail unless replacement is enabled. - -
+
+

Restore configuration

+

Configure how the selected backup is applied to this database.

+
@if ($resourceDbType === 'standalone-postgresql' && $dumpAll) Restoring all databases needs a PostgreSQL superuser (it recreates databases and roles), so Coolify cannot restrict it. Only use a backup you trust. The backup also replaces PostgreSQL administrator role passwords, including the destination administrator password. If the administrator password changes, update it in Coolify's database configuration after the restore. @endif - @if ($resourceDbType !== 'standalone-sqlite')
@endif +
+ +
+ +
+
- +
- -
+
+

Backup source

+

Choose a local file or an object stored in S3.

+
diff --git a/resources/views/livewire/project/database/import.blade.php b/resources/views/livewire/project/database/import.blade.php index bfd5bc928c..1c9901f66c 100644 --- a/resources/views/livewire/project/database/import.blade.php +++ b/resources/views/livewire/project/database/import.blade.php @@ -1,19 +1,13 @@ -
+
@if ($unsupported) - - - + @elseif (str($resourceStatus)->startsWith('running')) - + @else - - - + @endif
diff --git a/resources/views/livewire/project/service/database-backups.blade.php b/resources/views/livewire/project/service/database-backups.blade.php index 7db3174af6..87541d50ee 100644 --- a/resources/views/livewire/project/service/database-backups.blade.php +++ b/resources/views/livewire/project/service/database-backups.blade.php @@ -50,14 +50,20 @@

Scheduled backups

Automate backups for {{ $serviceDatabase->human_name ?: $serviceDatabase->name }}.

- @if (filled($serviceDatabase->custom_type) || ! $serviceDatabase->is_migrated) - @can('update', $serviceDatabase) - - + @can('update', $serviceDatabase) +
+ + - @endcan - @endif + @if (filled($serviceDatabase->custom_type) || ! $serviceDatabase->is_migrated) + + + + @endif +
+ @endcan
diff --git a/resources/views/livewire/project/service/import-backup.blade.php b/resources/views/livewire/project/service/import-backup.blade.php index 802ca7d10a..b2449d7689 100644 --- a/resources/views/livewire/project/service/import-backup.blade.php +++ b/resources/views/livewire/project/service/import-backup.blade.php @@ -1,39 +1,23 @@ -
- - {{ data_get_str($service, 'name')->limit(10) }} > Import Backup | Coolify - - - - -
-
- - -
- @if ($databases->isEmpty()) - - - - @else - - - - - @if ($selectedDatabase) - - @endif - @endif +
+ @if ($this->databases->isEmpty()) + + @else + @if ($this->databases->count() > 1) +
+
-
-
+ @endif + + @if ($this->selectedDatabase) + + @endif + @endif
diff --git a/resources/views/livewire/project/service/index.blade.php b/resources/views/livewire/project/service/index.blade.php index d5ecebed02..760f28edea 100644 --- a/resources/views/livewire/project/service/index.blade.php +++ b/resources/views/livewire/project/service/index.blade.php @@ -176,102 +176,98 @@ {{ data_get_str($serviceDatabase, 'name')->limit(10) }} | Coolify @endunless - @if ($currentRoute === 'project.service.database.import') - - @else -
-
-
- - - -
-
-
-

Public access

-
- - @if ($serviceDatabase->is_public) - - Proxy Logs - - - - Logs - - @endif -
-
-
-
-
- -
-
- @if ($isPublic) - - - Make private - - @else - {{-- Do not nest @if/@endif inside an opening tag: Blade component - compilation breaks and yields "unexpected token endif". --}} - - Make publicly available - - @endif -
-
- @if ($db_url_public) - + +
+
+ + + +
+
+
+

Public access

+
+ + @if ($serviceDatabase->is_public) + + Proxy Logs + + + + Logs + @endif
-
- - @include('livewire.project.service.advanced-settings') - -
-
- @can('delete', $serviceDatabase) - - @endcan -
-
- @can('update', $serviceDatabase) - - Save changes - @endcan +
+
+
+ +
+
+ @if ($isPublic) + + + Make private + + @else + {{-- Do not nest @if/@endif inside an opening tag: Blade component + compilation breaks and yields "unexpected token endif". --}} + + Make publicly available + + @endif +
+
+ @if ($db_url_public) + + @endif
- - @endif +
+ + @include('livewire.project.service.advanced-settings') + +
+
+ @can('delete', $serviceDatabase) + + @endcan +
+
+ @can('update', $serviceDatabase) + + Save changes + @endcan +
+
+ @endif
diff --git a/resources/views/livewire/project/service/volume-backup/index.blade.php b/resources/views/livewire/project/service/volume-backup/index.blade.php index 15a349c2fc..4c226cf318 100644 --- a/resources/views/livewire/project/service/volume-backup/index.blade.php +++ b/resources/views/livewire/project/service/volume-backup/index.blade.php @@ -123,6 +123,10 @@ helper="Manage database, persistent volume, and directory backup schedules for this service."> @can('update', $service) + + +
group(function () { Route::get('/', DatabaseConfiguration::class)->name('project.database.configuration'); Route::get('/environment-variables', DatabaseConfiguration::class)->name('project.database.environment-variables'); Route::get('/servers', DatabaseConfiguration::class)->name('project.database.servers'); - Route::get('/import-backup', DatabaseConfiguration::class)->name('project.database.import-backup')->middleware('can.update.resource'); + // Import opens in a modal on the backups page; keep old links working. + Route::redirect('/import-backup', '/project/{project_uuid}/environment/{environment_uuid}/database/{database_uuid}/backups')->name('project.database.import-backup'); Route::get('/persistent-storage', DatabaseConfiguration::class)->name('project.database.persistent-storage'); Route::get('/healthcheck', DatabaseConfiguration::class)->name('project.database.healthcheck'); Route::get('/webhooks', DatabaseConfiguration::class)->name('project.database.webhooks'); @@ -472,8 +472,9 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/analytics', ServiceConfiguration::class)->name('project.service.analytics'); Route::get('/environment-variables', ServiceConfiguration::class)->name('project.service.environment-variables'); Route::get('/storages', ServiceConfiguration::class)->name('project.service.storages'); - Route::get('/import-backup', ServiceImportBackup::class)->name('project.service.import-backup')->middleware('can.update.resource'); - Route::get('/import-backup/{stack_service_uuid}', ServiceImportBackup::class)->name('project.service.import-backup.database')->middleware('can.update.resource'); + // Import opens in a modal on the backups page; keep old links working. + Route::redirect('/import-backup', '/project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}/storage-backups')->name('project.service.import-backup'); + Route::redirect('/import-backup/{stack_service_uuid}', '/project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}/{stack_service_uuid}/backups')->name('project.service.import-backup.database'); Route::get('/storage-backups', Index::class)->name('project.service.volume-backups.index'); Route::get('/storage-backups/{backup_uuid}', Show::class)->name('project.service.volume-backups.show'); // S3 and retention settings moved into the general page; keep old links working. @@ -494,7 +495,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::redirect('/{stack_service_uuid}/backups/{backup_uuid}/retention', '/project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}/{stack_service_uuid}/backups/{backup_uuid}'); Route::get('/{stack_service_uuid}/backups/{backup_uuid}/executions', ServiceDatabaseBackups::class)->name('project.service.database.backup.executions'); Route::get('/{stack_service_uuid}/backups/{backup_uuid}/danger', ServiceDatabaseBackups::class)->name('project.service.database.backup.danger'); - Route::get('/{stack_service_uuid}/import', ServiceImportBackup::class)->name('project.service.database.import')->middleware('can.update.resource'); + Route::redirect('/{stack_service_uuid}/import', '/project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}/{stack_service_uuid}/backups')->name('project.service.database.import'); Route::get('/{stack_service_uuid}/advanced', ServiceIndex::class)->name('project.service.index.advanced'); Route::get('/{stack_service_uuid}', ServiceIndex::class)->name('project.service.index'); // Tasks open in a modal on the list page; keep old links (e.g. notifications) working. diff --git a/tests/Feature/ServiceResourceRoutingTest.php b/tests/Feature/ServiceResourceRoutingTest.php index 32d929e9b2..3dc6be20d6 100644 --- a/tests/Feature/ServiceResourceRoutingTest.php +++ b/tests/Feature/ServiceResourceRoutingTest.php @@ -5,6 +5,7 @@ use App\Jobs\VolumeBackupJob; use App\Livewire\Project\Database\Import as DatabaseImport; use App\Livewire\Project\Service\BackupExecutions; use App\Livewire\Project\Service\Heading; +use App\Livewire\Project\Service\ImportBackup as ServiceImportBackup; use App\Livewire\Project\Service\VolumeBackup\Index as ServiceVolumeBackupIndex; use App\Models\Environment; use App\Models\InstanceSettings; @@ -24,8 +25,6 @@ use App\Models\User; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Pagination\LengthAwarePaginator; -use Illuminate\Routing\Route as RoutingRoute; -use Illuminate\Routing\RouteCollection; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Once; @@ -125,37 +124,29 @@ test('does not open service database backups route from another team', function ])); })->throws(NotFoundHttpException::class); -test('does not open service import backup route from another team', function () { - $this->get(route('project.service.import-backup', [ +test('old service import backup urls redirect to the backups pages', function () { + $parameters = [ 'project_uuid' => $this->projectA->uuid, 'environment_uuid' => $this->environmentA->uuid, - 'service_uuid' => $this->otherService->uuid, - ]))->assertForbidden(); + 'service_uuid' => $this->ownService->uuid, + ]; + $databaseParameters = [...$parameters, 'stack_service_uuid' => $this->ownServiceDatabase->uuid]; + + $this->get(route('project.service.import-backup', $parameters)) + ->assertRedirect(route('project.service.volume-backups.index', $parameters)); + $this->get(route('project.service.import-backup.database', $databaseParameters)) + ->assertRedirect(route('project.service.database.backups', $databaseParameters)); + $this->get(route('project.service.database.import', $databaseParameters)) + ->assertRedirect(route('project.service.database.backups', $databaseParameters)); }); -test('does not resolve service database import component from another team', function () { - // The component resolves its resource from the current route parameters on mount. - // Register a full-page route for it ahead of the application's catch-all route. - $importRoute = new RoutingRoute( - ['GET', 'HEAD'], - '_tests/service-database-import/{project_uuid}/{environment_uuid}/{service_uuid}/{stack_service_uuid}', - ['uses' => DatabaseImport::class], - ); - $routes = new RouteCollection; - $routes->add($importRoute); - foreach (app('router')->getRoutes() as $route) { - $routes->add($route); - } - app('router')->setRoutes($routes); +test('does not open the service import backup modal for another team', function () { + Livewire::test(ServiceImportBackup::class, ['service' => $this->otherService])->assertForbidden(); +}); - $this->withoutExceptionHandling()->get(implode('/', [ - '/_tests/service-database-import', - $this->projectA->uuid, - $this->environmentA->uuid, - $this->otherService->uuid, - $this->otherServiceDatabase->uuid, - ])); -})->throws(ModelNotFoundException::class); +test('does not open the database import component for another team database', function () { + Livewire::test(DatabaseImport::class, ['resource' => $this->otherServiceDatabase])->assertForbidden(); +}); test('service heading does not hydrate with another team service', function () { Livewire::test(Heading::class, ['service' => $this->otherService]); @@ -319,45 +310,37 @@ test('service backup executions combine database execution history', function () ]))->assertSee("wire:click=\"openExecution('{$execution->uuid}')\"", false); }); -test('service import backup page selects from compatible databases', function () { +test('service import backup modal selects from compatible databases', function () { $secondDatabase = ServiceDatabase::create([ 'service_id' => $this->ownService->id, 'name' => 'analytics-db', 'image' => 'mysql:8', 'custom_type' => 'mysql', ]); - $importUrl = route('project.service.import-backup', [ - 'project_uuid' => $this->projectA->uuid, - 'environment_uuid' => $this->environmentA->uuid, - 'service_uuid' => $this->ownService->uuid, - ]); - $this->get($importUrl) - ->assertOk() - ->assertSee('Import Backup') + Livewire::test(ServiceImportBackup::class, ['service' => $this->ownService]) + ->assertSet('selectedDatabaseUuid', '') ->assertSee('own-db') - ->assertSee('analytics-db'); - - $this->get($importUrl.'/'.$secondDatabase->uuid) - ->assertOk() ->assertSee('analytics-db') + ->set('selectedDatabaseUuid', $secondDatabase->uuid) ->assertSee('Start the database first'); }); -test('service import backup redirects when exactly one compatible database exists', function () { - $this->get(route('project.service.import-backup', [ - 'project_uuid' => $this->projectA->uuid, - 'environment_uuid' => $this->environmentA->uuid, - 'service_uuid' => $this->ownService->uuid, - ]))->assertRedirectToRoute('project.service.import-backup.database', [ - 'project_uuid' => $this->projectA->uuid, - 'environment_uuid' => $this->environmentA->uuid, - 'service_uuid' => $this->ownService->uuid, - 'stack_service_uuid' => $this->ownServiceDatabase->uuid, +test('service import backup modal preselects the only compatible database', function () { + ServiceDatabase::create([ + 'service_id' => $this->ownService->id, + 'name' => 'cache-db', + 'image' => 'redis:7-alpine', + 'custom_type' => 'redis', ]); + + Livewire::test(ServiceImportBackup::class, ['service' => $this->ownService]) + ->assertSet('selectedDatabaseUuid', $this->ownServiceDatabase->uuid) + ->assertDontSee('cache-db') + ->assertSee('Start the database first'); }); -test('service import backup excludes unsupported databases', function () { +test('service import backup modal ignores an unsupported database', function () { $unsupportedDatabase = ServiceDatabase::create([ 'service_id' => $this->ownService->id, 'name' => 'cache-db', @@ -365,78 +348,17 @@ test('service import backup excludes unsupported databases', function () { 'custom_type' => 'redis', ]); - $this->get(route('project.service.import-backup', [ - 'project_uuid' => $this->projectA->uuid, - 'environment_uuid' => $this->environmentA->uuid, - 'service_uuid' => $this->ownService->uuid, - ])) - ->assertRedirectToRoute('project.service.import-backup.database', [ - 'project_uuid' => $this->projectA->uuid, - 'environment_uuid' => $this->environmentA->uuid, - 'service_uuid' => $this->ownService->uuid, - 'stack_service_uuid' => $this->ownServiceDatabase->uuid, - ]); - - $this->get(route('project.service.import-backup.database', [ - 'project_uuid' => $this->projectA->uuid, - 'environment_uuid' => $this->environmentA->uuid, - 'service_uuid' => $this->ownService->uuid, - 'stack_service_uuid' => $unsupportedDatabase->uuid, - ]))->assertNotFound(); + Livewire::test(ServiceImportBackup::class, ['service' => $this->ownService]) + ->set('selectedDatabaseUuid', $unsupportedDatabase->uuid) + ->assertDontSee('Start the database first'); }); -test('service import backup opens the selected compatible database', function () { - $secondDatabase = ServiceDatabase::create([ - 'service_id' => $this->ownService->id, - 'name' => 'analytics-db', - 'image' => 'mysql:8', - 'custom_type' => 'mysql', - ]); - - $this->get(route('project.service.import-backup.database', [ - 'project_uuid' => $this->projectA->uuid, - 'environment_uuid' => $this->environmentA->uuid, - 'service_uuid' => $this->ownService->uuid, - 'stack_service_uuid' => $secondDatabase->uuid, - ])) - ->assertOk() - ->assertSee('analytics-db') - ->assertSee('Start the database first'); -}); - -test('service import backup requires update authorization for the service and selected database', function () { +test('service import backup modal requires update authorization for the service', function () { $member = User::factory()->create(); $member->teams()->attach($this->teamA, ['role' => 'member']); $this->actingAs($member); - $parameters = [ - 'project_uuid' => $this->projectA->uuid, - 'environment_uuid' => $this->environmentA->uuid, - 'service_uuid' => $this->ownService->uuid, - ]; - - $this->get(route('project.service.import-backup', $parameters))->assertForbidden(); - $this->get(route('project.service.import-backup.database', [ - ...$parameters, - 'stack_service_uuid' => $this->ownServiceDatabase->uuid, - ]))->assertForbidden(); -}); - -test('legacy service database import redirects to the service import page with its database selected', function () { - $legacyUrl = route('project.service.database.import', [ - 'project_uuid' => $this->projectA->uuid, - 'environment_uuid' => $this->environmentA->uuid, - 'service_uuid' => $this->ownService->uuid, - 'stack_service_uuid' => $this->ownServiceDatabase->uuid, - ]); - $selectedImportUrl = route('project.service.import-backup.database', [ - 'project_uuid' => $this->projectA->uuid, - 'environment_uuid' => $this->environmentA->uuid, - 'service_uuid' => $this->ownService->uuid, - 'stack_service_uuid' => $this->ownServiceDatabase->uuid, - ]); - - $this->get($legacyUrl)->assertRedirect($selectedImportUrl); + Livewire::test(ServiceImportBackup::class, ['service' => $this->ownService])->assertForbidden(); }); test('service storage backups page includes schedules from all compose databases', function () {