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 `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"
+7 -50
View File
@@ -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
+4 -40
View File
@@ -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);
+27 -44
View File
@@ -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<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);
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']);
}
}
@@ -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'],
];
@@ -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'],
];
@@ -12,6 +12,10 @@
description="Automate database backups and track the latest execution for each schedule.">
@can('update', $database)
<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">
<livewire:project.database.create-scheduled-backup :database="$database" />
</x-modal-input>
@@ -40,8 +40,6 @@
<livewire:project.service.storage :resource="$database" />
@elseif ($currentRoute === 'project.database.healthcheck')
<livewire:project.database.health :database="$database" />
@elseif ($currentRoute === 'project.database.import-backup')
<livewire:project.database.import :resource="$database" />
@elseif ($currentRoute === 'project.database.webhooks')
<livewire:project.shared.webhooks :resource="$database" />
@elseif ($currentRoute === 'project.database.resource-limits')
@@ -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')
@@ -7,67 +7,79 @@
s3FileSize: $wire.entangle('s3FileSize'),
s3StorageId: $wire.entangle('s3StorageId'),
s3Path: $wire.entangle('s3Path'),
restoreType: null
}">
<script type="text/javascript" src="{{ URL::asset('js/dropzone.js') }}"></script>
@script
<script data-navigate-once>
Dropzone.options.myDropzone = {
chunking: true,
method: "POST",
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)
});
restoreType: null,
{{-- Dropzone attaches here, not by auto-discovery: the form can render after page load (in a modal, or when the database starts). --}}
mountDropzone(element) {
const wire = this.$wire;
const attach = () => {
window.Dropzone.autoDiscover = false;
if (element.dropzone) {
return;
}
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>
@endscript
<div class="application-settings-workspace flex flex-col gap-6">
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 });
},
}">
<div class="flex flex-col gap-6">
<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.
</x-callout>
<x-application.settings-section title="Restore configuration"
description="Configure how the selected backup is applied to this database.">
<div class="space-y-4">
<section>
<h3 class="text-sm font-semibold text-black dark:text-fg">Restore configuration</h3>
<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)
<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.
<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>
@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')
<div class="max-w-sm">
<x-forms.listbox id="dumpAll" label="Backup contents" live :options="[
@@ -116,12 +128,27 @@
canGate="update" :canResource="$this->resource" />
</div>
@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>
</x-application.settings-section>
</section>
<x-application.settings-section title="Backup source"
description="Choose a local file or an object stored in S3.">
<div class="grid gap-3 sm:grid-cols-2">
<section class="border-t border-neutral-200 pt-6 dark:border-white/[0.06]">
<h3 class="text-sm font-semibold text-black dark:text-fg">Backup source</h3>
<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'"
class="flex min-h-20 items-center gap-3 rounded-[10px] border p-3 text-left transition-colors"
:class="restoreType === 'file'
@@ -160,7 +187,7 @@
{{-- File Restore Section --}}
@can('update', $this->resource)
<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">
<div class="min-w-0 flex-1">
<x-forms.input label="File path on the server"
@@ -180,7 +207,7 @@
<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]!"
id="my-dropzone" wire:ignore>
x-init="mountDropzone($el)" wire:ignore>
@csrf
</form>
<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();
@endphp
<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">
<x-forms.listbox id="s3StorageId" label="S3 storage" :options="$s3StorageOptions"
placeholder="Select storage" live />
@@ -274,7 +301,7 @@
</div>
@endcan
@endif
</x-application.settings-section>
</section>
</div>
<x-process-dialog @databaserestore.window="processDialogOpen = true" closeWithX size="xl">
@@ -1,19 +1,13 @@
<div class="application-settings-form">
<div>
@if ($unsupported)
<x-application.settings-section title="Restore database"
description="Import a backup into this database.">
<x-empty title="Restore is not supported"
description="This database type does not currently support backup imports."
icon-name="database" size="sm" />
</x-application.settings-section>
<x-empty title="Restore is not supported"
description="This database type does not currently support backup imports."
icon-name="database" size="sm" />
@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
<x-application.settings-section title="Restore database"
description="Import a backup into this database.">
<x-empty title="Start the database first"
description="The database must be running before Coolify can restore a backup."
icon-name="database" size="sm" />
</x-application.settings-section>
<x-empty title="Start the database first"
description="The database must be running before Coolify can restore a backup."
icon-name="database" size="sm" />
@endif
</div>
@@ -50,14 +50,20 @@
<h2>Scheduled backups</h2>
<p>Automate backups for {{ $serviceDatabase->human_name ?: $serviceDatabase->name }}.</p>
</div>
@if (filled($serviceDatabase->custom_type) || ! $serviceDatabase->is_migrated)
@can('update', $serviceDatabase)
<x-modal-input buttonTitle="+ Add" title="New Scheduled Backup">
<livewire:project.database.create-scheduled-backup
:database="$serviceDatabase" />
@can('update', $serviceDatabase)
<div class="flex flex-wrap items-center gap-2">
<x-modal-input title="Import backup" buttonTitle="Import backup">
<livewire:project.database.import :resource="$serviceDatabase"
wire:key="service-database-import-{{ $serviceDatabase->uuid }}" />
</x-modal-input>
@endcan
@endif
@if (filled($serviceDatabase->custom_type) || ! $serviceDatabase->is_migrated)
<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 class="application-settings-section-body p-0!">
<livewire:project.database.scheduled-backups :database="$serviceDatabase" />
@@ -1,39 +1,23 @@
<div>
<x-slot:title>
{{ data_get_str($service, 'name')->limit(10) }} > Import Backup | Coolify
</x-slot>
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="request()->query()"
wire:key="service-heading-import-backup" />
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<x-service.configuration-sidebar :service="$service" current-route="project.service.import-backup" />
<div class="application-settings-form min-w-0 flex flex-col gap-6">
@if ($databases->isEmpty())
<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 class="flex flex-col gap-6">
@if ($this->databases->isEmpty())
<x-empty title="No compatible databases"
description="This service does not contain a database that supports backup imports."
icon-name="database" size="sm" />
@else
@if ($this->databases->count() > 1)
<div class="max-w-sm">
<x-forms.listbox id="selectedDatabaseUuid" label="Database" live required canGate="update"
:canResource="$service" placeholder="Select a database"
:options="$this->databases->map(fn ($database) => [
'value' => $database->uuid,
'label' => $database->human_name ?: $database->name,
])->all()" />
</div>
</div>
</section>
@endif
@if ($this->selectedDatabase)
<livewire:project.database.import :resource="$this->selectedDatabase"
:key="'service-import-' . $this->selectedDatabase->uuid" />
@endif
@endif
</div>
@@ -176,102 +176,98 @@
{{ data_get_str($serviceDatabase, 'name')->limit(10) }} | Coolify
</x-slot>
@endunless
@if ($currentRoute === 'project.service.database.import')
<livewire:project.database.import :resource="$serviceDatabase" :key="'import-' . $serviceDatabase->uuid" />
@else
<form wire:submit="submitDatabase" class="space-y-6">
<div class="space-y-5">
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.input canGate="update" :canResource="$serviceDatabase" label="Name" id="humanName"
placeholder="Name"></x-forms.input>
<x-forms.input canGate="update" :canResource="$serviceDatabase" label="Description"
id="description"></x-forms.input>
<x-forms.input class="sm:col-span-2" canGate="update" :canResource="$serviceDatabase" required
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>"
label="Image" id="image"></x-forms.input>
</div>
<div class="border-t border-neutral-200 pt-5 dark:border-white/[0.06]">
<div class="mb-4 flex items-center justify-between gap-2">
<h3 class="text-sm font-semibold text-black dark:text-fg">Public access</h3>
<div class="flex items-center gap-2">
<x-loading wire:loading wire:target="instantSave" />
@if ($serviceDatabase->is_public)
<x-process-dialog closeWithX size="xl">
<x-slot:title>Proxy Logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server" :resource="$service"
:servicesubtype="$serviceDatabase" container="{{ $serviceDatabase->uuid }}-proxy" :collapsible="false" lazy />
</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" />
<form wire:submit="submitDatabase" class="space-y-6">
<div class="space-y-5">
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.input canGate="update" :canResource="$serviceDatabase" label="Name" id="humanName"
placeholder="Name"></x-forms.input>
<x-forms.input canGate="update" :canResource="$serviceDatabase" label="Description"
id="description"></x-forms.input>
<x-forms.input class="sm:col-span-2" canGate="update" :canResource="$serviceDatabase" required
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>"
label="Image" id="image"></x-forms.input>
</div>
<div class="border-t border-neutral-200 pt-5 dark:border-white/[0.06]">
<div class="mb-4 flex items-center justify-between gap-2">
<h3 class="text-sm font-semibold text-black dark:text-fg">Public access</h3>
<div class="flex items-center gap-2">
<x-loading wire:loading wire:target="instantSave" />
@if ($serviceDatabase->is_public)
<x-process-dialog closeWithX size="xl">
<x-slot:title>Proxy Logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server" :resource="$service"
:servicesubtype="$serviceDatabase" container="{{ $serviceDatabase->uuid }}-proxy" :collapsible="false" lazy />
</x-slot:content>
<x-forms.button @click="processDialogOpen = true">Logs</x-forms.button>
</x-process-dialog>
@endif
</div>
</div>
</div>
@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 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
</div>
</div>
</form>
@endif
</div>
@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
</div>
</div>
@@ -123,6 +123,10 @@
helper="Manage database, persistent volume, and directory backup schedules for this service.">
@can('update', $service)
<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 class="relative" @click.outside="dropdownOpen = false">
<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\Service\Configuration as ServiceConfiguration;
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\VolumeBackup\Index;
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('/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.
+40 -118
View File
@@ -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 () {