mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
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:
1 parent
6254f44f16
commit
688134a994
17 files changed
+312
-473
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
Reference in new issue
Block a user