feat(backups): import backups from a dialog on the backups page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras BacsaiandClaude Opus 5.5 committed 2026-11-03 17:59:57 +01:00
1 parent 6254f44f16
commit 688134a994
17 files changed
+312 -473

No files matched your search

+4
View File
@@ -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 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 - 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 ### 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" - 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"
+7 -50
View File
@@ -9,6 +9,7 @@ use App\Models\StandaloneKeydb;
use App\Models\StandaloneRedis; use App\Models\StandaloneRedis;
use App\Traits\ListensToTeamChannel; use App\Traits\ListensToTeamChannel;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Locked; 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->authorize('view', $resource);
$this->resourceId = $resource->id; $this->resourceId = $resource->id;
@@ -72,57 +72,14 @@ class Import extends Component
public function render(): View 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(); return $this->resourceType::find($this->resourceId) ?? abort(404);
$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);
} }
private function isUnsupportedResource(object $resource): bool private function isUnsupportedResource(object $resource): bool
+4 -40
View File
@@ -5,7 +5,6 @@ namespace App\Livewire\Project\Database;
use App\Actions\Database\StartDatabaseImport; use App\Actions\Database\StartDatabaseImport;
use App\Models\S3Storage; use App\Models\S3Storage;
use App\Models\Server; use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceDatabase; use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse; use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly; use App\Models\StandaloneDragonfly;
@@ -17,6 +16,7 @@ use App\Support\DatabaseImport\DatabaseImportCommandBuilder;
use App\Support\DatabaseImport\DatabaseImportException; use App\Support\DatabaseImport\DatabaseImportException;
use App\Support\DatabaseImport\DatabaseImportSource; use App\Support\DatabaseImport\DatabaseImportSource;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -134,8 +134,6 @@ class ImportForm extends Component
#[Locked] #[Locked]
public string $resourceDbType = ''; public string $resourceDbType = '';
public array $parameters = [];
public array $containers = []; public array $containers = [];
public bool $scpInProgress = false; public bool $scpInProgress = false;
@@ -245,10 +243,9 @@ class ImportForm extends Component
$this->activityId = null; $this->activityId = null;
} }
public function mount() public function mount(Model $resource): void
{ {
$this->parameters = get_route_parameters(); $this->loadResource($resource);
$this->getContainers();
$this->loadAvailableS3Storages(); $this->loadAvailableS3Storages();
$this->initializeRestoreOptions(); $this->initializeRestoreOptions();
} }
@@ -315,42 +312,9 @@ class ImportForm extends Component
} }
} }
public function getContainers() private function loadResource(Model $resource): void
{ {
$this->containers = []; $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); $this->authorize('view', $resource);
+27 -44
View File
@@ -7,74 +7,57 @@ use App\Models\ServiceDatabase;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component; 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 class ImportBackup extends Component
{ {
use AuthorizesRequests; use AuthorizesRequests;
#[Locked]
public Service $service; public Service $service;
public Collection $databases;
public ?ServiceDatabase $selectedDatabase = null;
public string $selectedDatabaseUuid = ''; public string $selectedDatabaseUuid = '';
public array $parameters; public function mount(Service $service): void
public function mount(): mixed
{ {
$this->parameters = get_route_parameters(); $this->service = $service;
$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->authorize('update', $this->service); $this->authorize('update', $this->service);
$this->databases = $this->service->databases if ($this->databases->count() === 1) {
->filter(fn (ServiceDatabase $database): bool => $this->supportsImport($database)) $this->selectedDatabaseUuid = $this->databases->first()->uuid;
->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,
]);
} }
return null;
} }
public function updatedSelectedDatabaseUuid(): mixed /**
* @return Collection<int, ServiceDatabase>
*/
#[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); $database = $this->databases->firstWhere('uuid', $this->selectedDatabaseUuid);
abort_unless($database instanceof ServiceDatabase, 404); if (! $database instanceof ServiceDatabase) {
return null;
}
$this->authorize('update', $database); $this->authorize('update', $database);
return redirect()->route('project.service.import-backup.database', [ return $database;
...$this->parameters,
'stack_service_uuid' => $database->uuid,
]);
} }
public function render(): View public function render(): View
{ {
return view('livewire.project.service.import-backup'); return view('livewire.project.service.import-backup');
} }
private function supportsImport(ServiceDatabase $database): bool
{
return str($database->databaseType())->contains(['mysql', 'mariadb', 'postgres', 'mongo']);
}
} }
@@ -12,7 +12,6 @@
['label' => 'Environment Variables', 'route' => 'project.database.environment-variables', 'icon' => 'variables'], ['label' => 'Environment Variables', 'route' => 'project.database.environment-variables', 'icon' => 'variables'],
['label' => 'Persistent Storage', 'route' => 'project.database.persistent-storage', 'icon' => 'storages'], ['label' => 'Persistent Storage', 'route' => 'project.database.persistent-storage', 'icon' => 'storages'],
['label' => 'Backups', 'route' => 'project.database.backup.index', 'icon' => 'database', 'visible' => $database->isBackupSolutionAvailable()], ['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' => 'Servers', 'route' => 'project.database.servers', 'icon' => 'servers'],
['label' => 'Runtime Logs', 'route' => 'project.database.logs', 'icon' => 'unordered-list', 'navigate' => false], ['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')], ['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'], 'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Healthcheck'],
'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Files', 'Metrics'], 'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Files', 'Metrics'],
'Deploy' => ['Servers'], 'Deploy' => ['Servers'],
'Automation' => ['Webhooks', 'Backups', 'Import Backup'], 'Automation' => ['Webhooks', 'Backups'],
'Operations' => ['Resource Operations', 'Resource Limits', 'Tags'], 'Operations' => ['Resource Operations', 'Resource Limits', 'Tags'],
'Danger Zone' => ['Danger Zone'], 'Danger Zone' => ['Danger Zone'],
]; ];
@@ -13,7 +13,6 @@
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable], ['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable],
['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'], ['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'],
['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'], ['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' => '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' => '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')], ['label' => 'Files', 'route' => 'project.service.files', 'icon' => 'folder', 'visible' => auth()->user()?->can('canAccessTerminal')],
@@ -30,15 +29,13 @@
|| ($item['route'] === 'project.service.scheduled-tasks.show' || ($item['route'] === 'project.service.scheduled-tasks.show'
&& str($currentRoute)->startsWith('project.service.scheduled-tasks')) && str($currentRoute)->startsWith('project.service.scheduled-tasks'))
|| ($item['route'] === 'project.service.volume-backups.index' || ($item['route'] === 'project.service.volume-backups.index'
&& str($currentRoute)->startsWith('project.service.volume-backups')) && str($currentRoute)->startsWith('project.service.volume-backups')),
|| ($item['route'] === 'project.service.import-backup'
&& str($currentRoute)->startsWith('project.service.import-backup')),
]); ]);
$menuGroups = [ $menuGroups = [
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'], 'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'],
'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Files', 'Analytics'], 'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Files', 'Analytics'],
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups', 'Import Backup'], 'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'],
'Operations' => ['Resource Operations', 'Tags'], 'Operations' => ['Resource Operations', 'Tags'],
'Danger Zone' => ['Danger Zone'], 'Danger Zone' => ['Danger Zone'],
]; ];
@@ -12,6 +12,10 @@
description="Automate database backups and track the latest execution for each schedule."> description="Automate database backups and track the latest execution for each schedule.">
@can('update', $database) @can('update', $database)
<x-slot:actions> <x-slot:actions>
<x-modal-input title="Import backup" buttonTitle="Import backup">
<livewire:project.database.import :resource="$database"
wire:key="database-import-{{ $database->uuid }}" />
</x-modal-input>
<x-modal-input title="New Scheduled Backup" isHighlightedButton buttonTitle="+ Add"> <x-modal-input title="New Scheduled Backup" isHighlightedButton buttonTitle="+ Add">
<livewire:project.database.create-scheduled-backup :database="$database" /> <livewire:project.database.create-scheduled-backup :database="$database" />
</x-modal-input> </x-modal-input>
@@ -40,8 +40,6 @@
<livewire:project.service.storage :resource="$database" /> <livewire:project.service.storage :resource="$database" />
@elseif ($currentRoute === 'project.database.healthcheck') @elseif ($currentRoute === 'project.database.healthcheck')
<livewire:project.database.health :database="$database" /> <livewire:project.database.health :database="$database" />
@elseif ($currentRoute === 'project.database.import-backup')
<livewire:project.database.import :resource="$database" />
@elseif ($currentRoute === 'project.database.webhooks') @elseif ($currentRoute === 'project.database.webhooks')
<livewire:project.shared.webhooks :resource="$database" /> <livewire:project.shared.webhooks :resource="$database" />
@elseif ($currentRoute === 'project.database.resource-limits') @elseif ($currentRoute === 'project.database.resource-limits')
@@ -7,7 +7,6 @@
'active' => request()->routeIs('project.database.configuration') 'active' => request()->routeIs('project.database.configuration')
|| request()->routeIs('project.database.environment-variables') || request()->routeIs('project.database.environment-variables')
|| request()->routeIs('project.database.servers') || request()->routeIs('project.database.servers')
|| request()->routeIs('project.database.import-backup')
|| request()->routeIs('project.database.persistent-storage') || request()->routeIs('project.database.persistent-storage')
|| request()->routeIs('project.database.healthcheck') || request()->routeIs('project.database.healthcheck')
|| request()->routeIs('project.database.webhooks') || request()->routeIs('project.database.webhooks')
@@ -7,67 +7,79 @@
s3FileSize: $wire.entangle('s3FileSize'), s3FileSize: $wire.entangle('s3FileSize'),
s3StorageId: $wire.entangle('s3StorageId'), s3StorageId: $wire.entangle('s3StorageId'),
s3Path: $wire.entangle('s3Path'), s3Path: $wire.entangle('s3Path'),
restoreType: null restoreType: null,
}"> {{-- Dropzone attaches here, not by auto-discovery: the form can render after page load (in a modal, or when the database starts). --}}
<script type="text/javascript" src="{{ URL::asset('js/dropzone.js') }}"></script> mountDropzone(element) {
@script const wire = this.$wire;
<script data-navigate-once> const attach = () => {
Dropzone.options.myDropzone = { window.Dropzone.autoDiscover = false;
chunking: true, if (element.dropzone) {
method: "POST", return;
maxFilesize: 1000000000,
chunkSize: 10000000,
createImageThumbnails: false,
disablePreviews: true,
parallelChunkUploads: false,
init: function () {
let button = this.element.querySelector('button');
button.innerText = 'Select or drop a backup file here.'
this.on('sending', function (file, xhr, formData) {
const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
formData.append("_token", token);
});
this.on("addedfile", file => {
$wire.isUploading = true;
$wire.customLocation = '';
});
this.on('uploadprogress', function (file, progress, bytesSent) {
$wire.progress = progress;
});
this.on('complete', function (file) {
$wire.filename = file.name;
if (file.status === Dropzone.SUCCESS) {
$wire.selectUploadedFile(file.name);
}
$wire.filesize = Number(file.size / 1024 / 1024).toFixed(2) + ' MB';
$wire.isUploading = false;
});
this.on('error', function (file, message) {
$wire.error = true;
$wire.$dispatch('error', message.error)
});
} }
new window.Dropzone(element, {
chunking: true,
method: 'POST',
maxFilesize: 1000000000,
chunkSize: 10000000,
createImageThumbnails: false,
disablePreviews: true,
parallelChunkUploads: false,
dictDefaultMessage: 'Select or drop a backup file here.',
init() {
this.on('sending', (file, xhr, formData) => {
formData.append('_token', document.querySelector('meta[name=csrf-token]').getAttribute('content'));
});
this.on('addedfile', () => {
wire.isUploading = true;
wire.customLocation = '';
});
this.on('uploadprogress', (file, progress) => {
wire.progress = progress;
});
this.on('complete', (file) => {
wire.filename = file.name;
if (file.status === window.Dropzone.SUCCESS) {
wire.selectUploadedFile(file.name);
}
wire.filesize = Number(file.size / 1024 / 1024).toFixed(2) + ' MB';
wire.isUploading = false;
});
this.on('error', (file, message) => {
wire.error = true;
wire.$dispatch('error', message.error);
});
},
});
}; };
</script> if (window.Dropzone) {
@endscript attach();
<div class="application-settings-workspace flex flex-col gap-6"> 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 });
},
}">
<div class="flex flex-col gap-6">
<x-callout type="danger" title="Restoring a backup changes database data"> <x-callout type="danger" title="Restoring a backup changes database data">
Review the source and import command before continuing. Existing objects can cause the import to fail unless replacement is enabled. Review the source and import command before continuing. Existing objects can cause the import to fail unless replacement is enabled.
</x-callout> </x-callout>
<x-application.settings-section title="Restore configuration" <section>
description="Configure how the selected backup is applied to this database."> <h3 class="text-sm font-semibold text-black dark:text-fg">Restore configuration</h3>
<div class="space-y-4"> <p class="mt-0.5 text-xs text-neutral-500 dark:text-fg-dim">Configure how the selected backup is applied to this database.</p>
<div class="mt-4 space-y-4">
@if ($resourceDbType === 'standalone-postgresql' && $dumpAll) @if ($resourceDbType === 'standalone-postgresql' && $dumpAll)
<x-callout type="warning" title="Full restore runs as a superuser"> <x-callout type="warning" title="Full restore runs as a superuser">
Restoring all databases needs a PostgreSQL superuser (it recreates databases and roles), so Coolify cannot restrict it. Only use a backup you trust. Restoring all databases needs a PostgreSQL superuser (it recreates databases and roles), so Coolify cannot restrict it. Only use a backup you trust.
<span class="mt-1 block">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.</span> <span class="mt-1 block">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.</span>
</x-callout> </x-callout>
@endif @endif
<x-forms.textarea rows="10" readonly label="Import command"
helper="Coolify detects the backup format (SQL, archive, gzip, bz2, xz, zip, or tar) before it changes the database."
wire:model="restoreCommandText" canGate="update"
:canResource="$this->resource" />
@if ($resourceDbType !== 'standalone-sqlite') @if ($resourceDbType !== 'standalone-sqlite')
<div class="max-w-sm"> <div class="max-w-sm">
<x-forms.listbox id="dumpAll" label="Backup contents" live :options="[ <x-forms.listbox id="dumpAll" label="Backup contents" live :options="[
@@ -116,12 +128,27 @@
canGate="update" :canResource="$this->resource" /> canGate="update" :canResource="$this->resource" />
</div> </div>
@endif @endif
<div x-data="{ showCommand: false }">
<button type="button" @click="showCommand = !showCommand" :aria-expanded="showCommand"
class="inline-flex cursor-pointer items-center gap-1.5 text-xs font-medium text-neutral-500 hover:text-black dark:text-fg-dim dark:hover:text-fg">
<x-reicon name="chevron-down" class="size-3.5 transition-transform"
::class="showCommand ? '' : '-rotate-90'" />
<span x-text="showCommand ? 'Hide import command' : 'Show import command'">Show import command</span>
</button>
<div x-cloak x-show="showCommand" class="mt-3">
<x-forms.textarea rows="10" readonly label="Import command"
helper="Coolify detects the backup format (SQL, archive, gzip, bz2, xz, zip, or tar) before it changes the database."
wire:model="restoreCommandText" canGate="update"
:canResource="$this->resource" />
</div>
</div>
</div> </div>
</x-application.settings-section> </section>
<x-application.settings-section title="Backup source" <section class="border-t border-neutral-200 pt-6 dark:border-white/[0.06]">
description="Choose a local file or an object stored in S3."> <h3 class="text-sm font-semibold text-black dark:text-fg">Backup source</h3>
<div class="grid gap-3 sm:grid-cols-2"> <p class="mt-0.5 text-xs text-neutral-500 dark:text-fg-dim">Choose a local file or an object stored in S3.</p>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<button type="button" @click="restoreType = 'file'" <button type="button" @click="restoreType = 'file'"
class="flex min-h-20 items-center gap-3 rounded-[10px] border p-3 text-left transition-colors" class="flex min-h-20 items-center gap-3 rounded-[10px] border p-3 text-left transition-colors"
:class="restoreType === 'file' :class="restoreType === 'file'
@@ -160,7 +187,7 @@
{{-- File Restore Section --}} {{-- File Restore Section --}}
@can('update', $this->resource) @can('update', $this->resource)
<div x-cloak x-show="restoreType === 'file'" <div x-cloak x-show="restoreType === 'file'"
class="mt-4 rounded-[10px] border border-neutral-200 bg-neutral-50 p-4 dark:border-white/[0.08] dark:bg-white/[0.05]"> class="mt-4">
<form class="flex flex-col gap-3 sm:flex-row sm:items-end"> <form class="flex flex-col gap-3 sm:flex-row sm:items-end">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<x-forms.input label="File path on the server" <x-forms.input label="File path on the server"
@@ -180,7 +207,7 @@
<form action="{{ route('upload.backup', ['databaseUuid' => $resourceUuid]) }}" <form action="{{ route('upload.backup', ['databaseUuid' => $resourceUuid]) }}"
class="dropzone rounded-lg! border! border-dashed! border-neutral-300! bg-white! dark:border-white/[0.12]! dark:bg-white/[0.05]!" class="dropzone rounded-lg! border! border-dashed! border-neutral-300! bg-white! dark:border-white/[0.12]! dark:bg-white/[0.05]!"
id="my-dropzone" wire:ignore> x-init="mountDropzone($el)" wire:ignore>
@csrf @csrf
</form> </form>
<div x-show="isUploading" class="mt-3 h-1.5 overflow-hidden rounded-full bg-neutral-200 dark:bg-white/[0.08]"> <div x-show="isUploading" class="mt-3 h-1.5 overflow-hidden rounded-full bg-neutral-200 dark:bg-white/[0.08]">
@@ -227,7 +254,7 @@
->all(); ->all();
@endphp @endphp
<div x-cloak x-show="restoreType === 's3'" <div x-cloak x-show="restoreType === 's3'"
class="mt-4 rounded-[10px] border border-neutral-200 bg-neutral-50 p-4 dark:border-white/[0.08] dark:bg-white/[0.05]"> class="mt-4">
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<x-forms.listbox id="s3StorageId" label="S3 storage" :options="$s3StorageOptions" <x-forms.listbox id="s3StorageId" label="S3 storage" :options="$s3StorageOptions"
placeholder="Select storage" live /> placeholder="Select storage" live />
@@ -274,7 +301,7 @@
</div> </div>
@endcan @endcan
@endif @endif
</x-application.settings-section> </section>
</div> </div>
<x-process-dialog @databaserestore.window="processDialogOpen = true" closeWithX size="xl"> <x-process-dialog @databaserestore.window="processDialogOpen = true" closeWithX size="xl">
@@ -1,19 +1,13 @@
<div class="application-settings-form"> <div>
@if ($unsupported) @if ($unsupported)
<x-application.settings-section title="Restore database" <x-empty title="Restore is not supported"
description="Import a backup into this database."> description="This database type does not currently support backup imports."
<x-empty title="Restore is not supported" icon-name="database" size="sm" />
description="This database type does not currently support backup imports."
icon-name="database" size="sm" />
</x-application.settings-section>
@elseif (str($resourceStatus)->startsWith('running')) @elseif (str($resourceStatus)->startsWith('running'))
<livewire:project.database.import-form wire:key="database-import-form-{{ $resourceUuid }}" /> <livewire:project.database.import-form :resource="$resource" wire:key="database-import-form-{{ $resourceUuid }}" />
@else @else
<x-application.settings-section title="Restore database" <x-empty title="Start the database first"
description="Import a backup into this database."> description="The database must be running before Coolify can restore a backup."
<x-empty title="Start the database first" icon-name="database" size="sm" />
description="The database must be running before Coolify can restore a backup."
icon-name="database" size="sm" />
</x-application.settings-section>
@endif @endif
</div> </div>
@@ -50,14 +50,20 @@
<h2>Scheduled backups</h2> <h2>Scheduled backups</h2>
<p>Automate backups for {{ $serviceDatabase->human_name ?: $serviceDatabase->name }}.</p> <p>Automate backups for {{ $serviceDatabase->human_name ?: $serviceDatabase->name }}.</p>
</div> </div>
@if (filled($serviceDatabase->custom_type) || ! $serviceDatabase->is_migrated) @can('update', $serviceDatabase)
@can('update', $serviceDatabase) <div class="flex flex-wrap items-center gap-2">
<x-modal-input buttonTitle="+ Add" title="New Scheduled Backup"> <x-modal-input title="Import backup" buttonTitle="Import backup">
<livewire:project.database.create-scheduled-backup <livewire:project.database.import :resource="$serviceDatabase"
:database="$serviceDatabase" /> wire:key="service-database-import-{{ $serviceDatabase->uuid }}" />
</x-modal-input> </x-modal-input>
@endcan @if (filled($serviceDatabase->custom_type) || ! $serviceDatabase->is_migrated)
@endif <x-modal-input buttonTitle="+ Add" title="New Scheduled Backup">
<livewire:project.database.create-scheduled-backup
:database="$serviceDatabase" />
</x-modal-input>
@endif
</div>
@endcan
</div> </div>
<div class="application-settings-section-body p-0!"> <div class="application-settings-section-body p-0!">
<livewire:project.database.scheduled-backups :database="$serviceDatabase" /> <livewire:project.database.scheduled-backups :database="$serviceDatabase" />
@@ -1,39 +1,23 @@
<div> <div class="flex flex-col gap-6">
<x-slot:title> @if ($this->databases->isEmpty())
{{ data_get_str($service, 'name')->limit(10) }} > Import Backup | Coolify <x-empty title="No compatible databases"
</x-slot> description="This service does not contain a database that supports backup imports."
icon-name="database" size="sm" />
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="request()->query()" @else
wire:key="service-heading-import-backup" /> @if ($this->databases->count() > 1)
<div class="max-w-sm">
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0"> <x-forms.listbox id="selectedDatabaseUuid" label="Database" live required canGate="update"
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8"> :canResource="$service" placeholder="Select a database"
<x-service.configuration-sidebar :service="$service" current-route="project.service.import-backup" /> :options="$this->databases->map(fn ($database) => [
'value' => $database->uuid,
<div class="application-settings-form min-w-0 flex flex-col gap-6"> 'label' => $database->human_name ?: $database->name,
@if ($databases->isEmpty()) ])->all()" />
<x-application.settings-section title="Import Backup"
helper="Restore a backup into a database in this service.">
<x-empty title="No compatible databases"
description="This service does not contain a database that supports backup imports."
icon-name="database" size="sm" />
</x-application.settings-section>
@else
<x-application.settings-section title="Import Backup"
helper="Choose the database that should receive the backup.">
<x-forms.listbox id="selectedDatabaseUuid" label="Database" live required canGate="update"
:canResource="$service"
:options="$databases->map(fn ($database) => [
'value' => $database->uuid,
'label' => $database->human_name ?: $database->name,
])->all()" />
</x-application.settings-section>
@if ($selectedDatabase)
<livewire:project.database.import :key="'service-import-' . $selectedDatabase->uuid" />
@endif
@endif
</div> </div>
</div> @endif
</section>
@if ($this->selectedDatabase)
<livewire:project.database.import :resource="$this->selectedDatabase"
:key="'service-import-' . $this->selectedDatabase->uuid" />
@endif
@endif
</div> </div>
@@ -176,102 +176,98 @@
{{ data_get_str($serviceDatabase, 'name')->limit(10) }} | Coolify {{ data_get_str($serviceDatabase, 'name')->limit(10) }} | Coolify
</x-slot> </x-slot>
@endunless @endunless
@if ($currentRoute === 'project.service.database.import') <form wire:submit="submitDatabase" class="space-y-6">
<livewire:project.database.import :resource="$serviceDatabase" :key="'import-' . $serviceDatabase->uuid" /> <div class="space-y-5">
@else <div class="grid gap-4 sm:grid-cols-2">
<form wire:submit="submitDatabase" class="space-y-6"> <x-forms.input canGate="update" :canResource="$serviceDatabase" label="Name" id="humanName"
<div class="space-y-5"> placeholder="Name"></x-forms.input>
<div class="grid gap-4 sm:grid-cols-2"> <x-forms.input canGate="update" :canResource="$serviceDatabase" label="Description"
<x-forms.input canGate="update" :canResource="$serviceDatabase" label="Name" id="humanName" id="description"></x-forms.input>
placeholder="Name"></x-forms.input> <x-forms.input class="sm:col-span-2" canGate="update" :canResource="$serviceDatabase" required
<x-forms.input canGate="update" :canResource="$serviceDatabase" label="Description" helper="You can change the image you would like to deploy.<br><br><span class='dark:text-warning'>WARNING. You could corrupt your data. Only do it if you know what you are doing.</span>"
id="description"></x-forms.input> label="Image" id="image"></x-forms.input>
<x-forms.input class="sm:col-span-2" canGate="update" :canResource="$serviceDatabase" required </div>
helper="You can change the image you would like to deploy.<br><br><span class='dark:text-warning'>WARNING. You could corrupt your data. Only do it if you know what you are doing.</span>" <div class="border-t border-neutral-200 pt-5 dark:border-white/[0.06]">
label="Image" id="image"></x-forms.input> <div class="mb-4 flex items-center justify-between gap-2">
</div> <h3 class="text-sm font-semibold text-black dark:text-fg">Public access</h3>
<div class="border-t border-neutral-200 pt-5 dark:border-white/[0.06]"> <div class="flex items-center gap-2">
<div class="mb-4 flex items-center justify-between gap-2"> <x-loading wire:loading wire:target="instantSave" />
<h3 class="text-sm font-semibold text-black dark:text-fg">Public access</h3> @if ($serviceDatabase->is_public)
<div class="flex items-center gap-2"> <x-process-dialog closeWithX size="xl">
<x-loading wire:loading wire:target="instantSave" /> <x-slot:title>Proxy Logs</x-slot:title>
@if ($serviceDatabase->is_public) <x-slot:content>
<x-process-dialog closeWithX size="xl"> <livewire:project.shared.get-logs :server="$server" :resource="$service"
<x-slot:title>Proxy Logs</x-slot:title> :servicesubtype="$serviceDatabase" container="{{ $serviceDatabase->uuid }}-proxy" :collapsible="false" lazy />
<x-slot:content> </x-slot:content>
<livewire:project.shared.get-logs :server="$server" :resource="$service" <x-forms.button @click="processDialogOpen = true">Logs</x-forms.button>
:servicesubtype="$serviceDatabase" container="{{ $serviceDatabase->uuid }}-proxy" :collapsible="false" lazy /> </x-process-dialog>
</x-slot:content>
<x-forms.button @click="processDialogOpen = true">Logs</x-forms.button>
</x-process-dialog>
@endif
</div>
</div>
<div class="space-y-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end"
x-data="{ port: @js(filled($publicPort) ? (string) $publicPort : '') }"
@input="if ($event.target.matches('input[type=number], input:not([type])')) port = $event.target.value">
<div class="w-full sm:max-w-xs">
<x-forms.input type="number" canGate="update" :canResource="$serviceDatabase"
placeholder="5432" disabled="{{ $isPublic }}" id="publicPort"
label="Public Port" />
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
@if ($isPublic)
<x-status-badge status="Public" type="success" />
<x-forms.button canGate="update" :canResource="$serviceDatabase"
wire:click="disablePublicAccess">
Make private
</x-forms.button>
@else
{{-- Do not nest @if/@endif inside an <x-*> opening tag: Blade component
compilation breaks and yields "unexpected token endif". --}}
<x-forms.button canGate="update" :canResource="$serviceDatabase"
wire:click="enablePublicAccess"
x-bind:disabled="!String(port ?? '').trim()">
Make publicly available
</x-forms.button>
@endif
</div>
</div>
@if ($db_url_public)
<x-forms.input label="Database IP:PORT (public)"
helper="Your credentials are available in your environment variables." type="password"
readonly wire:model="db_url_public" />
@endif @endif
</div> </div>
</div> </div>
</div> <div class="space-y-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end"
@include('livewire.project.service.advanced-settings') x-data="{ port: @js(filled($publicPort) ? (string) $publicPort : '') }"
@input="if ($event.target.matches('input[type=number], input:not([type])')) port = $event.target.value">
<div data-service-resource-actions <div class="w-full sm:max-w-xs">
class="flex flex-wrap items-center justify-between gap-3 border-t border-neutral-200 pt-5 dark:border-white/[0.08]"> <x-forms.input type="number" canGate="update" :canResource="$serviceDatabase"
<div> placeholder="5432" disabled="{{ $isPublic }}" id="publicPort"
@can('delete', $serviceDatabase) label="Public Port" />
<x-modal-confirmation title="Confirm Service Database Deletion?" buttonTitle="Delete" </div>
isErrorButton submitAction="deleteDatabase" :actions="[ <div class="flex shrink-0 flex-wrap items-center gap-2">
'The selected service database container will be stopped and permanently deleted.', @if ($isPublic)
]" <x-status-badge status="Public" type="success" />
confirmationText="{{ Str::headline($serviceDatabase->name) }}" <x-forms.button canGate="update" :canResource="$serviceDatabase"
confirmationLabel="Please confirm the execution of the actions by entering the Service Database Name below" wire:click="disablePublicAccess">
shortConfirmationLabel="Service Database Name" /> Make private
@endcan </x-forms.button>
</div> @else
<div class="ml-auto flex items-center gap-2"> {{-- Do not nest @if/@endif inside an <x-*> opening tag: Blade component
@can('update', $serviceDatabase) compilation breaks and yields "unexpected token endif". --}}
<x-modal-confirmation wire:click="convertToApplication" title="Convert to Application" <x-forms.button canGate="update" :canResource="$serviceDatabase"
buttonTitle="Convert to Application" submitAction="convertToApplication" wire:click="enablePublicAccess"
:actions="['The selected resource will be converted to an application.']" x-bind:disabled="!String(port ?? '').trim()">
confirmationText="{{ Str::headline($serviceDatabase->name) }}" Make publicly available
confirmationLabel="Please confirm the execution of the actions by entering the Service Database Name below" </x-forms.button>
shortConfirmationLabel="Service Database Name" /> @endif
<x-forms.button type="submit" isHighlighted>Save changes</x-forms.button> </div>
@endcan </div>
@if ($db_url_public)
<x-forms.input label="Database IP:PORT (public)"
helper="Your credentials are available in your environment variables." type="password"
readonly wire:model="db_url_public" />
@endif
</div> </div>
</div> </div>
</form> </div>
@endif
@include('livewire.project.service.advanced-settings')
<div data-service-resource-actions
class="flex flex-wrap items-center justify-between gap-3 border-t border-neutral-200 pt-5 dark:border-white/[0.08]">
<div>
@can('delete', $serviceDatabase)
<x-modal-confirmation title="Confirm Service Database Deletion?" buttonTitle="Delete"
isErrorButton submitAction="deleteDatabase" :actions="[
'The selected service database container will be stopped and permanently deleted.',
]"
confirmationText="{{ Str::headline($serviceDatabase->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Database Name below"
shortConfirmationLabel="Service Database Name" />
@endcan
</div>
<div class="ml-auto flex items-center gap-2">
@can('update', $serviceDatabase)
<x-modal-confirmation wire:click="convertToApplication" title="Convert to Application"
buttonTitle="Convert to Application" submitAction="convertToApplication"
:actions="['The selected resource will be converted to an application.']"
confirmationText="{{ Str::headline($serviceDatabase->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Database Name below"
shortConfirmationLabel="Service Database Name" />
<x-forms.button type="submit" isHighlighted>Save changes</x-forms.button>
@endcan
</div>
</div>
</form>
@endif @endif
</div> </div>
</div> </div>
@@ -123,6 +123,10 @@
helper="Manage database, persistent volume, and directory backup schedules for this service."> helper="Manage database, persistent volume, and directory backup schedules for this service.">
@can('update', $service) @can('update', $service)
<x-slot:actions> <x-slot:actions>
<x-modal-input title="Import backup" buttonTitle="Import backup">
<livewire:project.service.import-backup :service="$service"
wire:key="service-import-backup-{{ $service->id }}" />
</x-modal-input>
<div x-data="{ dropdownOpen: false }"> <div x-data="{ dropdownOpen: false }">
<div class="relative" @click.outside="dropdownOpen = false"> <div class="relative" @click.outside="dropdownOpen = false">
<x-forms.button class="button-highlighted" @click="dropdownOpen = !dropdownOpen" <x-forms.button class="button-highlighted" @click="dropdownOpen = !dropdownOpen"
+6 -5
View File
@@ -61,7 +61,6 @@ use App\Livewire\Project\Resource\Create as ResourceCreate;
use App\Livewire\Project\Resource\Index as ResourceIndex; use App\Livewire\Project\Resource\Index as ResourceIndex;
use App\Livewire\Project\Service\Configuration as ServiceConfiguration; use App\Livewire\Project\Service\Configuration as ServiceConfiguration;
use App\Livewire\Project\Service\DatabaseBackups as ServiceDatabaseBackups; use App\Livewire\Project\Service\DatabaseBackups as ServiceDatabaseBackups;
use App\Livewire\Project\Service\ImportBackup as ServiceImportBackup;
use App\Livewire\Project\Service\Index as ServiceIndex; use App\Livewire\Project\Service\Index as ServiceIndex;
use App\Livewire\Project\Service\VolumeBackup\Index; use App\Livewire\Project\Service\VolumeBackup\Index;
use App\Livewire\Project\Service\VolumeBackup\Show; use App\Livewire\Project\Service\VolumeBackup\Show;
@@ -444,7 +443,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/', DatabaseConfiguration::class)->name('project.database.configuration'); Route::get('/', DatabaseConfiguration::class)->name('project.database.configuration');
Route::get('/environment-variables', DatabaseConfiguration::class)->name('project.database.environment-variables'); Route::get('/environment-variables', DatabaseConfiguration::class)->name('project.database.environment-variables');
Route::get('/servers', DatabaseConfiguration::class)->name('project.database.servers'); 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('/persistent-storage', DatabaseConfiguration::class)->name('project.database.persistent-storage');
Route::get('/healthcheck', DatabaseConfiguration::class)->name('project.database.healthcheck'); Route::get('/healthcheck', DatabaseConfiguration::class)->name('project.database.healthcheck');
Route::get('/webhooks', DatabaseConfiguration::class)->name('project.database.webhooks'); 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('/analytics', ServiceConfiguration::class)->name('project.service.analytics');
Route::get('/environment-variables', ServiceConfiguration::class)->name('project.service.environment-variables'); Route::get('/environment-variables', ServiceConfiguration::class)->name('project.service.environment-variables');
Route::get('/storages', ServiceConfiguration::class)->name('project.service.storages'); Route::get('/storages', ServiceConfiguration::class)->name('project.service.storages');
Route::get('/import-backup', ServiceImportBackup::class)->name('project.service.import-backup')->middleware('can.update.resource'); // Import opens in a modal on the backups page; keep old links working.
Route::get('/import-backup/{stack_service_uuid}', ServiceImportBackup::class)->name('project.service.import-backup.database')->middleware('can.update.resource'); 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', Index::class)->name('project.service.volume-backups.index');
Route::get('/storage-backups/{backup_uuid}', Show::class)->name('project.service.volume-backups.show'); 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. // 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::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}/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}/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}/advanced', ServiceIndex::class)->name('project.service.index.advanced');
Route::get('/{stack_service_uuid}', ServiceIndex::class)->name('project.service.index'); 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. // Tasks open in a modal on the list page; keep old links (e.g. notifications) working.
+40 -118
View File
@@ -5,6 +5,7 @@ use App\Jobs\VolumeBackupJob;
use App\Livewire\Project\Database\Import as DatabaseImport; use App\Livewire\Project\Database\Import as DatabaseImport;
use App\Livewire\Project\Service\BackupExecutions; use App\Livewire\Project\Service\BackupExecutions;
use App\Livewire\Project\Service\Heading; 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\Livewire\Project\Service\VolumeBackup\Index as ServiceVolumeBackupIndex;
use App\Models\Environment; use App\Models\Environment;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
@@ -24,8 +25,6 @@ use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Routing\Route as RoutingRoute;
use Illuminate\Routing\RouteCollection;
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Once; use Illuminate\Support\Once;
@@ -125,37 +124,29 @@ test('does not open service database backups route from another team', function
])); ]));
})->throws(NotFoundHttpException::class); })->throws(NotFoundHttpException::class);
test('does not open service import backup route from another team', function () { test('old service import backup urls redirect to the backups pages', function () {
$this->get(route('project.service.import-backup', [ $parameters = [
'project_uuid' => $this->projectA->uuid, 'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid, 'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->otherService->uuid, 'service_uuid' => $this->ownService->uuid,
]))->assertForbidden(); ];
$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 () { test('does not open the service import backup modal for another team', function () {
// The component resolves its resource from the current route parameters on mount. Livewire::test(ServiceImportBackup::class, ['service' => $this->otherService])->assertForbidden();
// 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);
$this->withoutExceptionHandling()->get(implode('/', [ test('does not open the database import component for another team database', function () {
'/_tests/service-database-import', Livewire::test(DatabaseImport::class, ['resource' => $this->otherServiceDatabase])->assertForbidden();
$this->projectA->uuid, });
$this->environmentA->uuid,
$this->otherService->uuid,
$this->otherServiceDatabase->uuid,
]));
})->throws(ModelNotFoundException::class);
test('service heading does not hydrate with another team service', function () { test('service heading does not hydrate with another team service', function () {
Livewire::test(Heading::class, ['service' => $this->otherService]); 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); ]))->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([ $secondDatabase = ServiceDatabase::create([
'service_id' => $this->ownService->id, 'service_id' => $this->ownService->id,
'name' => 'analytics-db', 'name' => 'analytics-db',
'image' => 'mysql:8', 'image' => 'mysql:8',
'custom_type' => 'mysql', '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) Livewire::test(ServiceImportBackup::class, ['service' => $this->ownService])
->assertOk() ->assertSet('selectedDatabaseUuid', '')
->assertSee('Import Backup')
->assertSee('own-db') ->assertSee('own-db')
->assertSee('analytics-db');
$this->get($importUrl.'/'.$secondDatabase->uuid)
->assertOk()
->assertSee('analytics-db') ->assertSee('analytics-db')
->set('selectedDatabaseUuid', $secondDatabase->uuid)
->assertSee('Start the database first'); ->assertSee('Start the database first');
}); });
test('service import backup redirects when exactly one compatible database exists', function () { test('service import backup modal preselects the only compatible database', function () {
$this->get(route('project.service.import-backup', [ ServiceDatabase::create([
'project_uuid' => $this->projectA->uuid, 'service_id' => $this->ownService->id,
'environment_uuid' => $this->environmentA->uuid, 'name' => 'cache-db',
'service_uuid' => $this->ownService->uuid, 'image' => 'redis:7-alpine',
]))->assertRedirectToRoute('project.service.import-backup.database', [ 'custom_type' => 'redis',
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
'stack_service_uuid' => $this->ownServiceDatabase->uuid,
]); ]);
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([ $unsupportedDatabase = ServiceDatabase::create([
'service_id' => $this->ownService->id, 'service_id' => $this->ownService->id,
'name' => 'cache-db', 'name' => 'cache-db',
@@ -365,78 +348,17 @@ test('service import backup excludes unsupported databases', function () {
'custom_type' => 'redis', 'custom_type' => 'redis',
]); ]);
$this->get(route('project.service.import-backup', [ Livewire::test(ServiceImportBackup::class, ['service' => $this->ownService])
'project_uuid' => $this->projectA->uuid, ->set('selectedDatabaseUuid', $unsupportedDatabase->uuid)
'environment_uuid' => $this->environmentA->uuid, ->assertDontSee('Start the database first');
'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();
}); });
test('service import backup opens the selected compatible database', function () { test('service import backup modal requires update authorization for the service', 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 () {
$member = User::factory()->create(); $member = User::factory()->create();
$member->teams()->attach($this->teamA, ['role' => 'member']); $member->teams()->attach($this->teamA, ['role' => 'member']);
$this->actingAs($member); $this->actingAs($member);
$parameters = [ Livewire::test(ServiceImportBackup::class, ['service' => $this->ownService])->assertForbidden();
'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);
}); });
test('service storage backups page includes schedules from all compose databases', function () { test('service storage backups page includes schedules from all compose databases', function () {