From 8289dcc3ca057f2e39d15cf7e5f30910002c1a2c Mon Sep 17 00:00:00 2001 From: Murat Aslan Date: Tue, 9 Dec 2025 10:40:19 +0300 Subject: [PATCH 1/4] feat: add ServiceDatabase restore/import support Add support for restoring/importing backups in ServiceDatabase (Docker Compose databases). Changes: - Add ServiceDatabase case in buildRestoreCommand() method - Handle ServiceDatabase container naming in getContainers() - Support PostgreSQL, MySQL, MariaDB, MongoDB detection via databaseType() - Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.) Fixes #7529 --- app/Livewire/Project/Database/Import.php | 59 ++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index 26feb1a5e..0b6e31ea0 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -176,8 +176,23 @@ public function mount() public function updatedDumpAll($value) { - switch ($this->resource->getMorphClass()) { + $morphClass = $this->resource->getMorphClass(); + + // Handle ServiceDatabase by checking the database type + if ($morphClass === \App\Models\ServiceDatabase::class) { + $dbType = $this->resource->databaseType(); + if (str_contains($dbType, 'mysql')) { + $morphClass = 'mysql'; + } elseif (str_contains($dbType, 'mariadb')) { + $morphClass = 'mariadb'; + } elseif (str_contains($dbType, 'postgres')) { + $morphClass = 'postgresql'; + } + } + + switch ($morphClass) { case \App\Models\StandaloneMariadb::class: + case 'mariadb': if ($value === true) { $this->mariadbRestoreCommand = <<<'EOD' for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do @@ -193,6 +208,7 @@ public function updatedDumpAll($value) } break; case \App\Models\StandaloneMysql::class: + case 'mysql': if ($value === true) { $this->mysqlRestoreCommand = <<<'EOD' for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do @@ -208,6 +224,7 @@ public function updatedDumpAll($value) } break; case \App\Models\StandalonePostgresql::class: + case 'postgresql': if ($value === true) { $this->postgresqlRestoreCommand = <<<'EOD' psql -U $POSTGRES_USER -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \ @@ -236,7 +253,14 @@ public function getContainers() $this->authorize('view', $resource); $this->resource = $resource; $this->server = $this->resource->destination->server; - $this->container = $this->resource->uuid; + + // Handle ServiceDatabase container naming + if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + $this->container = $this->resource->name . '-' . $this->resource->service->uuid; + } else { + $this->container = $this->resource->uuid; + } + if (str(data_get($this, 'resource.status'))->startsWith('running')) { $this->containers->push($this->container); } @@ -249,6 +273,15 @@ public function getContainers() ) { $this->unsupported = true; } + + // Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.) + if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + $dbType = $this->resource->databaseType(); + if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') || + str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) { + $this->unsupported = true; + } + } } public function checkFile() @@ -575,8 +608,25 @@ public function restoreFromS3() public function buildRestoreCommand(string $tmpPath): string { - switch ($this->resource->getMorphClass()) { + $morphClass = $this->resource->getMorphClass(); + + // Handle ServiceDatabase by checking the database type + if ($morphClass === \App\Models\ServiceDatabase::class) { + $dbType = $this->resource->databaseType(); + if (str_contains($dbType, 'mysql')) { + $morphClass = 'mysql'; + } elseif (str_contains($dbType, 'mariadb')) { + $morphClass = 'mariadb'; + } elseif (str_contains($dbType, 'postgres')) { + $morphClass = 'postgresql'; + } elseif (str_contains($dbType, 'mongo')) { + $morphClass = 'mongodb'; + } + } + + switch ($morphClass) { case \App\Models\StandaloneMariadb::class: + case 'mariadb': $restoreCommand = $this->mariadbRestoreCommand; if ($this->dumpAll) { $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD"; @@ -585,6 +635,7 @@ public function buildRestoreCommand(string $tmpPath): string } break; case \App\Models\StandaloneMysql::class: + case 'mysql': $restoreCommand = $this->mysqlRestoreCommand; if ($this->dumpAll) { $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD"; @@ -593,6 +644,7 @@ public function buildRestoreCommand(string $tmpPath): string } break; case \App\Models\StandalonePostgresql::class: + case 'postgresql': $restoreCommand = $this->postgresqlRestoreCommand; if ($this->dumpAll) { $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres"; @@ -601,6 +653,7 @@ public function buildRestoreCommand(string $tmpPath): string } break; case \App\Models\StandaloneMongodb::class: + case 'mongodb': $restoreCommand = $this->mongodbRestoreCommand; if ($this->dumpAll === false) { $restoreCommand .= "{$tmpPath}"; From 60683875426a58a0e8a803ce9eb95276fddf87e3 Mon Sep 17 00:00:00 2001 From: Murat Aslan Date: Fri, 12 Dec 2025 11:47:16 +0300 Subject: [PATCH 2/4] feat: add import backup UI for ServiceDatabase Add Import Backup section to ServiceDatabase view for supported database types (PostgreSQL, MySQL, MariaDB, MongoDB). This enables users to import backups directly from the ServiceDatabase configuration page, utilizing the existing Import Livewire component. --- .../livewire/project/service/database.blade.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/resources/views/livewire/project/service/database.blade.php b/resources/views/livewire/project/service/database.blade.php index 1ebb3a44f..eebe78c3f 100644 --- a/resources/views/livewire/project/service/database.blade.php +++ b/resources/views/livewire/project/service/database.blade.php @@ -49,4 +49,19 @@ instantSave="instantSaveLogDrain" id="isLogDrainEnabled" label="Drain Logs" /> + + @php + $dbType = $database->databaseType(); + $supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo']; + $isSupported = collect($supportedTypes)->contains(fn($type) => str_contains($dbType, $type)); + @endphp + + @if ($isSupported) + @can('update', $database) +
+

Import Backup

+ +
+ @endcan + @endif From 9466ad4a489ae17970dede27f8d7dd4d6d87fa6b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 2 Jan 2026 13:46:53 +0100 Subject: [PATCH 3/4] fix(service): handle missing service database and redirect to configuration --- app/Livewire/Project/Service/Index.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index 8d37d3e31..50772101a 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -46,6 +46,13 @@ public function mount() $this->serviceApplication->getFilesFromServer(); } else { $this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first(); + if (! $this->serviceDatabase) { + return redirect()->route('project.service.configuration', [ + 'project_uuid' => $this->parameters['project_uuid'], + 'environment_uuid' => $this->parameters['environment_uuid'], + 'service_uuid' => $this->parameters['service_uuid'], + ]); + } $this->serviceDatabase->getFilesFromServer(); } $this->s3s = currentTeam()->s3s; From 796bb3a19d625d323943a2c064241044b0222326 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:29:48 +0100 Subject: [PATCH 4/4] feat: Refactor service database management and backup functionalities - Introduced a new sidebar component for service database navigation. - Updated routes for database import and backup functionalities. - Refactored the database import view to improve clarity and maintainability. - Consolidated service application and database views into a more cohesive structure. - Removed deprecated service application view and integrated its functionalities into the service index. - Enhanced user experience with modal confirmations for critical actions. - Improved code readability and organization across various components. --- app/Http/Controllers/UploadController.php | 12 +- app/Livewire/Project/Database/BackupEdit.php | 14 +- app/Livewire/Project/Database/Import.php | 206 ++++++-- app/Livewire/Project/Service/Database.php | 228 --------- .../Project/Service/DatabaseBackups.php | 64 +++ app/Livewire/Project/Service/Index.php | 482 +++++++++++++++++- .../Service/ServiceApplicationView.php | 345 ------------- bootstrap/helpers/shared.php | 22 +- .../service-database/sidebar.blade.php | 24 + .../database/backup-executions.blade.php | 13 +- .../project/database/configuration.blade.php | 8 +- .../project/database/import.blade.php | 26 +- .../project/service/configuration.blade.php | 2 +- .../service/database-backups.blade.php | 23 + .../project/service/database.blade.php | 67 --- .../livewire/project/service/index.blade.php | 277 ++++++++-- .../service-application-view.blade.php | 149 ------ routes/web.php | 5 +- 18 files changed, 1056 insertions(+), 911 deletions(-) delete mode 100644 app/Livewire/Project/Service/Database.php create mode 100644 app/Livewire/Project/Service/DatabaseBackups.php delete mode 100644 app/Livewire/Project/Service/ServiceApplicationView.php create mode 100644 resources/views/components/service-database/sidebar.blade.php create mode 100644 resources/views/livewire/project/service/database-backups.blade.php delete mode 100644 resources/views/livewire/project/service/database.blade.php delete mode 100644 resources/views/livewire/project/service/service-application-view.blade.php diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php index 4d34a1000..93847589a 100644 --- a/app/Http/Controllers/UploadController.php +++ b/app/Http/Controllers/UploadController.php @@ -13,7 +13,8 @@ class UploadController extends BaseController { public function upload(Request $request) { - $resource = getResourceByUuid(request()->route('databaseUuid'), data_get(auth()->user()->currentTeam(), 'id')); + $databaseIdentifier = request()->route('databaseUuid'); + $resource = getResourceByUuid($databaseIdentifier, data_get(auth()->user()->currentTeam(), 'id')); if (is_null($resource)) { return response()->json(['error' => 'You do not have permission for this database'], 500); } @@ -26,7 +27,10 @@ public function upload(Request $request) $save = $receiver->receive(); if ($save->isFinished()) { - return $this->saveFile($save->getFile(), $resource); + // Use the original identifier from the route to maintain path consistency + // For ServiceDatabase: {name}-{service_uuid} + // For standalone databases: {uuid} + return $this->saveFile($save->getFile(), $databaseIdentifier); } $handler = $save->handler(); @@ -57,10 +61,10 @@ public function upload(Request $request) // 'mime_type' => $mime // ]); // } - protected function saveFile(UploadedFile $file, $resource) + protected function saveFile(UploadedFile $file, string $resourceIdentifier) { $mime = str_replace('/', '-', $file->getMimeType()); - $filePath = "upload/{$resource->uuid}"; + $filePath = "upload/{$resourceIdentifier}"; $finalPath = storage_path('app/'.$filePath); $file->move($finalPath, 'restore'); diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index d70c52411..35262d7b0 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -8,7 +8,6 @@ use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; -use Spatie\Url\Url; class BackupEdit extends Component { @@ -184,13 +183,14 @@ public function delete($password) $this->backup->delete(); if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) { - $previousUrl = url()->previous(); - $url = Url::fromString($previousUrl); - $url = $url->withoutQueryParameter('selectedBackupId'); - $url = $url->withFragment('backups'); - $url = $url->getPath()."#{$url->getFragment()}"; + $serviceDatabase = $this->backup->database; - return redirect($url); + return redirect()->route('project.service.database.backups', [ + 'project_uuid' => $this->parameters['project_uuid'], + 'environment_uuid' => $this->parameters['environment_uuid'], + 'service_uuid' => $serviceDatabase->service->uuid, + 'stack_service_uuid' => $serviceDatabase->uuid, + ]); } else { return redirect()->route('project.database.backup.index', $this->parameters); } diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index 0b6e31ea0..2077ad66c 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -4,9 +4,11 @@ use App\Models\S3Storage; use App\Models\Server; +use App\Models\Service; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Storage; +use Livewire\Attributes\Computed; use Livewire\Component; class Import extends Component @@ -101,11 +103,23 @@ private function validateServerPath(string $path): bool public bool $unsupported = false; - public $resource; + // Store IDs instead of models for proper Livewire serialization + public ?int $resourceId = null; - public $parameters; + public ?string $resourceType = null; - public $containers; + public ?int $serverId = null; + + // View-friendly properties to avoid computed property access in Blade + public string $resourceUuid = ''; + + public string $resourceStatus = ''; + + public string $resourceDbType = ''; + + public array $parameters = []; + + public array $containers = []; public bool $scpInProgress = false; @@ -121,8 +135,6 @@ private function validateServerPath(string $path): bool public bool $error = false; - public Server $server; - public string $container; public array $importCommands = []; @@ -144,7 +156,7 @@ private function validateServerPath(string $path): bool public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive='; // S3 Restore properties - public $availableS3Storages = []; + public array $availableS3Storages = []; public ?int $s3StorageId = null; @@ -152,6 +164,26 @@ private function validateServerPath(string $path): bool public ?int $s3FileSize = null; + #[Computed] + public function resource() + { + if ($this->resourceId === null || $this->resourceType === null) { + return null; + } + + return $this->resourceType::find($this->resourceId); + } + + #[Computed] + public function server() + { + if ($this->serverId === null) { + return null; + } + + return Server::find($this->serverId); + } + public function getListeners() { $userId = Auth::id(); @@ -177,7 +209,7 @@ public function mount() public function updatedDumpAll($value) { $morphClass = $this->resource->getMorphClass(); - + // Handle ServiceDatabase by checking the database type if ($morphClass === \App\Models\ServiceDatabase::class) { $dbType = $this->resource->databaseType(); @@ -189,7 +221,7 @@ public function updatedDumpAll($value) $morphClass = 'postgresql'; } } - + switch ($morphClass) { case \App\Models\StandaloneMariadb::class: case 'mariadb': @@ -242,42 +274,95 @@ public function updatedDumpAll($value) public function getContainers() { - $this->containers = collect(); - if (! data_get($this->parameters, 'database_uuid')) { - abort(404); - } - $resource = getResourceByUuid($this->parameters['database_uuid'], data_get(auth()->user()->currentTeam(), 'id')); - if (is_null($resource)) { - abort(404); - } - $this->authorize('view', $resource); - $this->resource = $resource; - $this->server = $this->resource->destination->server; - - // Handle ServiceDatabase container naming - if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) { - $this->container = $this->resource->name . '-' . $this->resource->service->uuid; + $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'); + $service = Service::whereUuid($serviceUuid)->first(); + if (! $service) { + abort(404); + } + $resource = $service->databases()->whereUuid($stackServiceUuid)->first(); + if (is_null($resource)) { + abort(404); + } } else { - $this->container = $this->resource->uuid; + abort(404); } - - if (str(data_get($this, 'resource.status'))->startsWith('running')) { - $this->containers->push($this->container); + + $this->authorize('view', $resource); + + // Store IDs for Livewire serialization + $this->resourceId = $resource->id; + $this->resourceType = get_class($resource); + + // Store view-friendly properties + $this->resourceStatus = $resource->status ?? ''; + + // Handle ServiceDatabase server access differently + if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + $server = $resource->service?->server; + if (! $server) { + abort(404, 'Server not found for this service database.'); + } + $this->serverId = $server->id; + $this->container = $resource->name.'-'.$resource->service->uuid; + $this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID + + // Determine database type for ServiceDatabase + $dbType = $resource->databaseType(); + if (str_contains($dbType, 'postgres')) { + $this->resourceDbType = 'standalone-postgresql'; + } elseif (str_contains($dbType, 'mysql')) { + $this->resourceDbType = 'standalone-mysql'; + } elseif (str_contains($dbType, 'mariadb')) { + $this->resourceDbType = 'standalone-mariadb'; + } elseif (str_contains($dbType, 'mongo')) { + $this->resourceDbType = 'standalone-mongodb'; + } else { + $this->resourceDbType = $dbType; + } + } else { + $server = $resource->destination?->server; + if (! $server) { + abort(404, 'Server not found for this database.'); + } + $this->serverId = $server->id; + $this->container = $resource->uuid; + $this->resourceUuid = $resource->uuid; + $this->resourceDbType = $resource->type(); + } + + if (str($resource->status)->startsWith('running')) { + $this->containers[] = $this->container; } if ( - $this->resource->getMorphClass() === \App\Models\StandaloneRedis::class || - $this->resource->getMorphClass() === \App\Models\StandaloneKeydb::class || - $this->resource->getMorphClass() === \App\Models\StandaloneDragonfly::class || - $this->resource->getMorphClass() === \App\Models\StandaloneClickhouse::class + $resource->getMorphClass() === \App\Models\StandaloneRedis::class || + $resource->getMorphClass() === \App\Models\StandaloneKeydb::class || + $resource->getMorphClass() === \App\Models\StandaloneDragonfly::class || + $resource->getMorphClass() === \App\Models\StandaloneClickhouse::class ) { $this->unsupported = true; } - + // Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.) - if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) { - $dbType = $this->resource->databaseType(); - if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') || + if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + $dbType = $resource->databaseType(); + if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') || str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) { $this->unsupported = true; } @@ -294,6 +379,12 @@ public function checkFile() return; } + if (! $this->server) { + $this->dispatch('error', 'Server not found. Please refresh the page.'); + + return; + } + try { $escapedPath = escapeshellarg($this->customLocation); $result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false); @@ -319,15 +410,22 @@ public function runImport() return; } + + if (! $this->server) { + $this->dispatch('error', 'Server not found. Please refresh the page.'); + + return; + } + try { $this->importRunning = true; $this->importCommands = []; - $backupFileName = "upload/{$this->resource->uuid}/restore"; + $backupFileName = "upload/{$this->resourceUuid}/restore"; // Check if an uploaded file exists first (takes priority over custom location) if (Storage::exists($backupFileName)) { $path = Storage::path($backupFileName); - $tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resource->uuid; + $tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid; instant_scp($path, $tmpPath, $this->server); Storage::delete($backupFileName); $this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}"; @@ -338,7 +436,7 @@ public function runImport() return; } - $tmpPath = '/tmp/restore_'.$this->resource->uuid; + $tmpPath = '/tmp/restore_'.$this->resourceUuid; $escapedCustomLocation = escapeshellarg($this->customLocation); $this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}"; } else { @@ -348,7 +446,7 @@ public function runImport() } // Copy the restore command to a script file - $scriptPath = "/tmp/restore_{$this->resource->uuid}.sh"; + $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh"; $restoreCommand = $this->buildRestoreCommand($tmpPath); @@ -388,9 +486,11 @@ public function loadAvailableS3Storages() try { $this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description']) ->where('is_usable', true) - ->get(); + ->get() + ->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description]) + ->toArray(); } catch (\Throwable $e) { - $this->availableS3Storages = collect(); + $this->availableS3Storages = []; } } @@ -493,6 +593,12 @@ public function restoreFromS3() return; } + if (! $this->server) { + $this->dispatch('error', 'Server not found. Please refresh the page.'); + + return; + } + try { $this->importRunning = true; @@ -526,14 +632,18 @@ public function restoreFromS3() $fullImageName = "{$helperImage}:{$latestVersion}"; // Get the database destination network - $destinationNetwork = $this->resource->destination->network ?? 'coolify'; + if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + $destinationNetwork = $this->resource->service->destination->network ?? 'coolify'; + } else { + $destinationNetwork = $this->resource->destination->network ?? 'coolify'; + } // Generate unique names for this operation - $containerName = "s3-restore-{$this->resource->uuid}"; + $containerName = "s3-restore-{$this->resourceUuid}"; $helperTmpPath = '/tmp/'.basename($cleanPath); - $serverTmpPath = "/tmp/s3-restore-{$this->resource->uuid}-".basename($cleanPath); - $containerTmpPath = "/tmp/restore_{$this->resource->uuid}-".basename($cleanPath); - $scriptPath = "/tmp/restore_{$this->resource->uuid}.sh"; + $serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath); + $containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath); + $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh"; // Prepare all commands in sequence $commands = []; @@ -609,7 +719,7 @@ public function restoreFromS3() public function buildRestoreCommand(string $tmpPath): string { $morphClass = $this->resource->getMorphClass(); - + // Handle ServiceDatabase by checking the database type if ($morphClass === \App\Models\ServiceDatabase::class) { $dbType = $this->resource->databaseType(); @@ -623,7 +733,7 @@ public function buildRestoreCommand(string $tmpPath): string $morphClass = 'mongodb'; } } - + switch ($morphClass) { case \App\Models\StandaloneMariadb::class: case 'mariadb': diff --git a/app/Livewire/Project/Service/Database.php b/app/Livewire/Project/Service/Database.php deleted file mode 100644 index 301c39dee..000000000 --- a/app/Livewire/Project/Service/Database.php +++ /dev/null @@ -1,228 +0,0 @@ - 'nullable', - 'description' => 'nullable', - 'image' => 'required', - 'excludeFromStatus' => 'required|boolean', - 'publicPort' => 'nullable|integer', - 'isPublic' => 'required|boolean', - 'isLogDrainEnabled' => 'required|boolean', - ]; - - public function render() - { - return view('livewire.project.service.database'); - } - - public function mount() - { - try { - $this->parameters = get_route_parameters(); - $this->authorize('view', $this->database); - if ($this->database->is_public) { - $this->db_url_public = $this->database->getServiceDatabaseUrl(); - } - $this->refreshFileStorages(); - $this->syncData(false); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - private function syncData(bool $toModel = false): void - { - if ($toModel) { - $this->database->human_name = $this->humanName; - $this->database->description = $this->description; - $this->database->image = $this->image; - $this->database->exclude_from_status = $this->excludeFromStatus; - $this->database->public_port = $this->publicPort; - $this->database->is_public = $this->isPublic; - $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; - } else { - $this->humanName = $this->database->human_name; - $this->description = $this->database->description; - $this->image = $this->database->image; - $this->excludeFromStatus = $this->database->exclude_from_status ?? false; - $this->publicPort = $this->database->public_port; - $this->isPublic = $this->database->is_public ?? false; - $this->isLogDrainEnabled = $this->database->is_log_drain_enabled ?? false; - } - } - - public function delete($password) - { - try { - $this->authorize('delete', $this->database); - - if (! verifyPasswordConfirmation($password, $this)) { - return; - } - - $this->database->delete(); - $this->dispatch('success', 'Database deleted.'); - - return redirectRoute($this, 'project.service.configuration', $this->parameters); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function instantSaveExclude() - { - try { - $this->authorize('update', $this->database); - $this->submit(); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function instantSaveLogDrain() - { - try { - $this->authorize('update', $this->database); - if (! $this->database->service->destination->server->isLogDrainEnabled()) { - $this->isLogDrainEnabled = false; - $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); - - return; - } - $this->submit(); - $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function convertToApplication() - { - try { - $this->authorize('update', $this->database); - $service = $this->database->service; - $serviceDatabase = $this->database; - - // Check if application with same name already exists - if ($service->applications()->where('name', $serviceDatabase->name)->exists()) { - throw new \Exception('An application with this name already exists.'); - } - - // Create new parameters removing database_uuid - $redirectParams = collect($this->parameters) - ->except('database_uuid') - ->all(); - - DB::transaction(function () use ($service, $serviceDatabase) { - $service->applications()->create([ - 'name' => $serviceDatabase->name, - 'human_name' => $serviceDatabase->human_name, - 'description' => $serviceDatabase->description, - 'exclude_from_status' => $serviceDatabase->exclude_from_status, - 'is_log_drain_enabled' => $serviceDatabase->is_log_drain_enabled, - 'image' => $serviceDatabase->image, - 'service_id' => $service->id, - 'is_migrated' => true, - ]); - $serviceDatabase->delete(); - }); - - return redirectRoute($this, 'project.service.configuration', $redirectParams); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function instantSave() - { - try { - $this->authorize('update', $this->database); - if ($this->isPublic && ! $this->publicPort) { - $this->dispatch('error', 'Public port is required.'); - $this->isPublic = false; - - return; - } - $this->syncData(true); - if ($this->database->is_public) { - if (! str($this->database->status)->startsWith('running')) { - $this->dispatch('error', 'Database must be started to be publicly accessible.'); - $this->isPublic = false; - $this->database->is_public = false; - - return; - } - StartDatabaseProxy::run($this->database); - $this->db_url_public = $this->database->getServiceDatabaseUrl(); - $this->dispatch('success', 'Database is now publicly accessible.'); - } else { - StopDatabaseProxy::run($this->database); - $this->db_url_public = null; - $this->dispatch('success', 'Database is no longer publicly accessible.'); - } - $this->submit(); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function refreshFileStorages() - { - $this->fileStorages = $this->database->fileStorages()->get(); - } - - public function submit() - { - try { - $this->authorize('update', $this->database); - $this->validate(); - $this->syncData(true); - $this->database->save(); - $this->database->refresh(); - $this->syncData(false); - updateCompose($this->database); - $this->dispatch('success', 'Database saved.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } finally { - $this->dispatch('generateDockerCompose'); - } - } -} diff --git a/app/Livewire/Project/Service/DatabaseBackups.php b/app/Livewire/Project/Service/DatabaseBackups.php new file mode 100644 index 000000000..826a6c1ff --- /dev/null +++ b/app/Livewire/Project/Service/DatabaseBackups.php @@ -0,0 +1,64 @@ + '$refresh']; + + public function mount() + { + try { + $this->parameters = get_route_parameters(); + $this->query = request()->query(); + $this->service = Service::whereUuid($this->parameters['service_uuid'])->first(); + if (! $this->service) { + return redirect()->route('dashboard'); + } + $this->authorize('view', $this->service); + + $this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first(); + if (! $this->serviceDatabase) { + return redirect()->route('project.service.configuration', [ + 'project_uuid' => $this->parameters['project_uuid'], + 'environment_uuid' => $this->parameters['environment_uuid'], + 'service_uuid' => $this->parameters['service_uuid'], + ]); + } + + // Check if backups are supported for this database + if (! $this->serviceDatabase->isBackupSolutionAvailable() && ! $this->serviceDatabase->is_migrated) { + return redirect()->route('project.service.index', $this->parameters); + } + + // Check if import is supported for this database type + $dbType = $this->serviceDatabase->databaseType(); + $supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo']; + $this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type)); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.project.service.database-backups'); + } +} diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index 50772101a..aa678922d 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -2,12 +2,17 @@ namespace App\Livewire\Project\Service; +use App\Actions\Database\StartDatabaseProxy; +use App\Actions\Database\StopDatabaseProxy; +use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Livewire\Component; +use Spatie\Url\Url; class Index extends Component { @@ -19,6 +24,10 @@ class Index extends Component public ?ServiceDatabase $serviceDatabase = null; + public ?string $resourceType = null; + + public ?string $currentRoute = null; + public array $parameters; public array $query; @@ -27,7 +36,67 @@ class Index extends Component public $s3s; - protected $listeners = ['generateDockerCompose', 'refreshScheduledBackups' => '$refresh']; + public ?Server $server = null; + + // Database-specific properties + public ?string $db_url_public = null; + + public $fileStorages; + + public ?string $humanName = null; + + public ?string $description = null; + + public ?string $image = null; + + public bool $excludeFromStatus = false; + + public ?int $publicPort = null; + + public bool $isPublic = false; + + public bool $isLogDrainEnabled = false; + + public bool $isImportSupported = false; + + // Application-specific properties + public $docker_cleanup = true; + + public $delete_volumes = true; + + public $domainConflicts = []; + + public $showDomainConflictModal = false; + + public $forceSaveDomains = false; + + public $showPortWarningModal = false; + + public $forceRemovePort = false; + + public $requiredPort = null; + + public ?string $fqdn = null; + + public bool $isGzipEnabled = false; + + public bool $isStripprefixEnabled = false; + + protected $listeners = ['generateDockerCompose', 'refreshScheduledBackups' => '$refresh', 'refreshFileStorages']; + + protected $rules = [ + 'humanName' => 'nullable', + 'description' => 'nullable', + 'image' => 'required', + 'excludeFromStatus' => 'required|boolean', + 'publicPort' => 'nullable|integer', + 'isPublic' => 'required|boolean', + 'isLogDrainEnabled' => 'required|boolean', + // Application-specific rules + 'fqdn' => 'nullable', + 'isGzipEnabled' => 'nullable|boolean', + 'isStripprefixEnabled' => 'nullable|boolean', + ]; public function mount() { @@ -35,6 +104,7 @@ public function mount() $this->services = collect([]); $this->parameters = get_route_parameters(); $this->query = request()->query(); + $this->currentRoute = request()->route()->getName(); $this->service = Service::whereUuid($this->parameters['service_uuid'])->first(); if (! $this->service) { return redirect()->route('dashboard'); @@ -43,7 +113,9 @@ public function mount() $service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first(); if ($service) { $this->serviceApplication = $service; + $this->resourceType = 'application'; $this->serviceApplication->getFilesFromServer(); + $this->initializeApplicationProperties(); } else { $this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first(); if (! $this->serviceDatabase) { @@ -53,7 +125,9 @@ public function mount() 'service_uuid' => $this->parameters['service_uuid'], ]); } + $this->resourceType = 'database'; $this->serviceDatabase->getFilesFromServer(); + $this->initializeDatabaseProperties(); } $this->s3s = currentTeam()->s3s; } catch (\Throwable $e) { @@ -61,6 +135,42 @@ public function mount() } } + private function initializeDatabaseProperties(): void + { + $this->server = $this->serviceDatabase->service->destination->server; + if ($this->serviceDatabase->is_public) { + $this->db_url_public = $this->serviceDatabase->getServiceDatabaseUrl(); + } + $this->refreshFileStorages(); + $this->syncDatabaseData(false); + + // Check if import is supported for this database type + $dbType = $this->serviceDatabase->databaseType(); + $supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo']; + $this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type)); + } + + private function syncDatabaseData(bool $toModel = false): void + { + if ($toModel) { + $this->serviceDatabase->human_name = $this->humanName; + $this->serviceDatabase->description = $this->description; + $this->serviceDatabase->image = $this->image; + $this->serviceDatabase->exclude_from_status = $this->excludeFromStatus; + $this->serviceDatabase->public_port = $this->publicPort; + $this->serviceDatabase->is_public = $this->isPublic; + $this->serviceDatabase->is_log_drain_enabled = $this->isLogDrainEnabled; + } else { + $this->humanName = $this->serviceDatabase->human_name; + $this->description = $this->serviceDatabase->description; + $this->image = $this->serviceDatabase->image; + $this->excludeFromStatus = $this->serviceDatabase->exclude_from_status ?? false; + $this->publicPort = $this->serviceDatabase->public_port; + $this->isPublic = $this->serviceDatabase->is_public ?? false; + $this->isLogDrainEnabled = $this->serviceDatabase->is_log_drain_enabled ?? false; + } + } + public function generateDockerCompose() { try { @@ -71,6 +181,376 @@ public function generateDockerCompose() } } + // Database-specific methods + public function refreshFileStorages() + { + if ($this->serviceDatabase) { + $this->fileStorages = $this->serviceDatabase->fileStorages()->get(); + } + } + + public function deleteDatabase($password) + { + try { + $this->authorize('delete', $this->serviceDatabase); + + if (! verifyPasswordConfirmation($password, $this)) { + return; + } + + $this->serviceDatabase->delete(); + $this->dispatch('success', 'Database deleted.'); + + return redirectRoute($this, 'project.service.configuration', $this->parameters); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function instantSaveExclude() + { + try { + $this->authorize('update', $this->serviceDatabase); + $this->submitDatabase(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function instantSaveLogDrain() + { + try { + $this->authorize('update', $this->serviceDatabase); + if (! $this->serviceDatabase->service->destination->server->isLogDrainEnabled()) { + $this->isLogDrainEnabled = false; + $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); + + return; + } + $this->submitDatabase(); + $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function convertToApplication() + { + try { + $this->authorize('update', $this->serviceDatabase); + $service = $this->serviceDatabase->service; + $serviceDatabase = $this->serviceDatabase; + + // Check if application with same name already exists + if ($service->applications()->where('name', $serviceDatabase->name)->exists()) { + throw new \Exception('An application with this name already exists.'); + } + + // Create new parameters removing database_uuid + $redirectParams = collect($this->parameters) + ->except('database_uuid') + ->all(); + + DB::transaction(function () use ($service, $serviceDatabase) { + $service->applications()->create([ + 'name' => $serviceDatabase->name, + 'human_name' => $serviceDatabase->human_name, + 'description' => $serviceDatabase->description, + 'exclude_from_status' => $serviceDatabase->exclude_from_status, + 'is_log_drain_enabled' => $serviceDatabase->is_log_drain_enabled, + 'image' => $serviceDatabase->image, + 'service_id' => $service->id, + 'is_migrated' => true, + ]); + $serviceDatabase->delete(); + }); + + return redirectRoute($this, 'project.service.configuration', $redirectParams); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function instantSave() + { + try { + $this->authorize('update', $this->serviceDatabase); + if ($this->isPublic && ! $this->publicPort) { + $this->dispatch('error', 'Public port is required.'); + $this->isPublic = false; + + return; + } + $this->syncDatabaseData(true); + if ($this->serviceDatabase->is_public) { + if (! str($this->serviceDatabase->status)->startsWith('running')) { + $this->dispatch('error', 'Database must be started to be publicly accessible.'); + $this->isPublic = false; + $this->serviceDatabase->is_public = false; + + return; + } + StartDatabaseProxy::run($this->serviceDatabase); + $this->db_url_public = $this->serviceDatabase->getServiceDatabaseUrl(); + $this->dispatch('success', 'Database is now publicly accessible.'); + } else { + StopDatabaseProxy::run($this->serviceDatabase); + $this->db_url_public = null; + $this->dispatch('success', 'Database is no longer publicly accessible.'); + } + $this->submitDatabase(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function submitDatabase() + { + try { + $this->authorize('update', $this->serviceDatabase); + $this->validate(); + $this->syncDatabaseData(true); + $this->serviceDatabase->save(); + $this->serviceDatabase->refresh(); + $this->syncDatabaseData(false); + updateCompose($this->serviceDatabase); + $this->dispatch('success', 'Database saved.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } finally { + $this->dispatch('generateDockerCompose'); + } + } + + // Application-specific methods + private function initializeApplicationProperties(): void + { + $this->requiredPort = $this->serviceApplication->getRequiredPort(); + $this->syncApplicationData(false); + } + + private function syncApplicationData(bool $toModel = false): void + { + if ($toModel) { + $this->serviceApplication->human_name = $this->humanName; + $this->serviceApplication->description = $this->description; + $this->serviceApplication->fqdn = $this->fqdn; + $this->serviceApplication->image = $this->image; + $this->serviceApplication->exclude_from_status = $this->excludeFromStatus; + $this->serviceApplication->is_log_drain_enabled = $this->isLogDrainEnabled; + $this->serviceApplication->is_gzip_enabled = $this->isGzipEnabled; + $this->serviceApplication->is_stripprefix_enabled = $this->isStripprefixEnabled; + } else { + $this->humanName = $this->serviceApplication->human_name; + $this->description = $this->serviceApplication->description; + $this->fqdn = $this->serviceApplication->fqdn; + $this->image = $this->serviceApplication->image; + $this->excludeFromStatus = data_get($this->serviceApplication, 'exclude_from_status', false); + $this->isLogDrainEnabled = data_get($this->serviceApplication, 'is_log_drain_enabled', false); + $this->isGzipEnabled = data_get($this->serviceApplication, 'is_gzip_enabled', true); + $this->isStripprefixEnabled = data_get($this->serviceApplication, 'is_stripprefix_enabled', true); + } + } + + public function instantSaveApplication() + { + try { + $this->authorize('update', $this->serviceApplication); + $this->submitApplication(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function instantSaveApplicationSettings() + { + try { + $this->authorize('update', $this->serviceApplication); + $this->serviceApplication->is_gzip_enabled = $this->isGzipEnabled; + $this->serviceApplication->is_stripprefix_enabled = $this->isStripprefixEnabled; + $this->serviceApplication->exclude_from_status = $this->excludeFromStatus; + $this->serviceApplication->save(); + $this->dispatch('success', 'Settings saved.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function instantSaveApplicationAdvanced() + { + try { + $this->authorize('update', $this->serviceApplication); + if (! $this->serviceApplication->service->destination->server->isLogDrainEnabled()) { + $this->isLogDrainEnabled = false; + $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); + + return; + } + $this->syncApplicationData(true); + $this->serviceApplication->save(); + $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function deleteApplication($password) + { + try { + $this->authorize('delete', $this->serviceApplication); + + if (! verifyPasswordConfirmation($password, $this)) { + return; + } + + $this->serviceApplication->delete(); + $this->dispatch('success', 'Application deleted.'); + + return redirect()->route('project.service.configuration', $this->parameters); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function convertToDatabase() + { + try { + $this->authorize('update', $this->serviceApplication); + $service = $this->serviceApplication->service; + $serviceApplication = $this->serviceApplication; + + if ($service->databases()->where('name', $serviceApplication->name)->exists()) { + throw new \Exception('A database with this name already exists.'); + } + + $redirectParams = collect($this->parameters) + ->except('database_uuid') + ->all(); + DB::transaction(function () use ($service, $serviceApplication) { + $service->databases()->create([ + 'name' => $serviceApplication->name, + 'human_name' => $serviceApplication->human_name, + 'description' => $serviceApplication->description, + 'exclude_from_status' => $serviceApplication->exclude_from_status, + 'is_log_drain_enabled' => $serviceApplication->is_log_drain_enabled, + 'image' => $serviceApplication->image, + 'service_id' => $service->id, + 'is_migrated' => true, + ]); + $serviceApplication->delete(); + }); + + return redirect()->route('project.service.configuration', $redirectParams); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function confirmDomainUsage() + { + $this->forceSaveDomains = true; + $this->showDomainConflictModal = false; + $this->submitApplication(); + } + + public function confirmRemovePort() + { + $this->forceRemovePort = true; + $this->showPortWarningModal = false; + $this->submitApplication(); + } + + public function cancelRemovePort() + { + $this->showPortWarningModal = false; + $this->syncApplicationData(false); + } + + public function submitApplication() + { + try { + $this->authorize('update', $this->serviceApplication); + $this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); + $this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString(); + $domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) { + $domain = trim($domain); + Url::fromString($domain, ['http', 'https']); + + return str($domain)->lower(); + }); + $this->fqdn = $domains->unique()->implode(','); + $warning = sslipDomainWarning($this->fqdn); + if ($warning) { + $this->dispatch('warning', __('warning.sslipdomain')); + } + + $this->syncApplicationData(true); + + if (! $this->forceSaveDomains) { + $result = checkDomainUsage(resource: $this->serviceApplication); + if ($result['hasConflicts']) { + $this->domainConflicts = $result['conflicts']; + $this->showDomainConflictModal = true; + + return; + } + } else { + $this->forceSaveDomains = false; + } + + if (! $this->forceRemovePort) { + $requiredPort = $this->serviceApplication->getRequiredPort(); + + if ($requiredPort !== null) { + $fqdns = str($this->fqdn)->trim()->explode(','); + $missingPort = false; + + foreach ($fqdns as $fqdn) { + $fqdn = trim($fqdn); + if (empty($fqdn)) { + continue; + } + + $port = ServiceApplication::extractPortFromUrl($fqdn); + if ($port === null) { + $missingPort = true; + break; + } + } + + if ($missingPort) { + $this->requiredPort = $requiredPort; + $this->showPortWarningModal = true; + + return; + } + } + } else { + $this->forceRemovePort = false; + } + + $this->validate(); + $this->serviceApplication->save(); + $this->serviceApplication->refresh(); + $this->syncApplicationData(false); + updateCompose($this->serviceApplication); + if (str($this->serviceApplication->fqdn)->contains(',')) { + $this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.

Only use multiple domains if you know what you are doing.'); + } else { + ! $warning && $this->dispatch('success', 'Service saved.'); + } + $this->dispatch('generateDockerCompose'); + } catch (\Throwable $e) { + $originalFqdn = $this->serviceApplication->getOriginal('fqdn'); + if ($originalFqdn !== $this->serviceApplication->fqdn) { + $this->serviceApplication->fqdn = $originalFqdn; + $this->syncApplicationData(false); + } + + return handleError($e, $this); + } + } + public function render() { return view('livewire.project.service.index'); diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php deleted file mode 100644 index 4302c05fb..000000000 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ /dev/null @@ -1,345 +0,0 @@ - 'nullable', - 'description' => 'nullable', - 'fqdn' => 'nullable', - 'image' => 'string|nullable', - 'excludeFromStatus' => 'required|boolean', - 'application.required_fqdn' => 'required|boolean', - 'isLogDrainEnabled' => 'nullable|boolean', - 'isGzipEnabled' => 'nullable|boolean', - 'isStripprefixEnabled' => 'nullable|boolean', - ]; - - public function instantSave() - { - try { - $this->authorize('update', $this->application); - $this->submit(); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function instantSaveSettings() - { - try { - $this->authorize('update', $this->application); - // Save checkbox states without port validation - $this->application->is_gzip_enabled = $this->isGzipEnabled; - $this->application->is_stripprefix_enabled = $this->isStripprefixEnabled; - $this->application->exclude_from_status = $this->excludeFromStatus; - $this->application->save(); - $this->dispatch('success', 'Settings saved.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function instantSaveAdvanced() - { - try { - $this->authorize('update', $this->application); - if (! $this->application->service->destination->server->isLogDrainEnabled()) { - $this->isLogDrainEnabled = false; - $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); - - return; - } - // Sync component properties to model - $this->application->human_name = $this->humanName; - $this->application->description = $this->description; - $this->application->fqdn = $this->fqdn; - $this->application->image = $this->image; - $this->application->exclude_from_status = $this->excludeFromStatus; - $this->application->is_log_drain_enabled = $this->isLogDrainEnabled; - $this->application->is_gzip_enabled = $this->isGzipEnabled; - $this->application->is_stripprefix_enabled = $this->isStripprefixEnabled; - $this->application->save(); - $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function delete($password) - { - try { - $this->authorize('delete', $this->application); - - if (! verifyPasswordConfirmation($password, $this)) { - return; - } - - $this->application->delete(); - $this->dispatch('success', 'Application deleted.'); - - return redirect()->route('project.service.configuration', $this->parameters); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function mount() - { - try { - $this->parameters = get_route_parameters(); - $this->authorize('view', $this->application); - $this->requiredPort = $this->application->getRequiredPort(); - $this->syncData(); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function confirmRemovePort() - { - $this->forceRemovePort = true; - $this->showPortWarningModal = false; - $this->submit(); - } - - public function cancelRemovePort() - { - $this->showPortWarningModal = false; - $this->syncData(); // Reset to original FQDN - } - - public function syncData(bool $toModel = false): void - { - if ($toModel) { - $this->validate(); - - // Sync to model - $this->application->human_name = $this->humanName; - $this->application->description = $this->description; - $this->application->fqdn = $this->fqdn; - $this->application->image = $this->image; - $this->application->exclude_from_status = $this->excludeFromStatus; - $this->application->is_log_drain_enabled = $this->isLogDrainEnabled; - $this->application->is_gzip_enabled = $this->isGzipEnabled; - $this->application->is_stripprefix_enabled = $this->isStripprefixEnabled; - - $this->application->save(); - } else { - // Sync from model - $this->humanName = $this->application->human_name; - $this->description = $this->application->description; - $this->fqdn = $this->application->fqdn; - $this->image = $this->application->image; - $this->excludeFromStatus = data_get($this->application, 'exclude_from_status', false); - $this->isLogDrainEnabled = data_get($this->application, 'is_log_drain_enabled', false); - $this->isGzipEnabled = data_get($this->application, 'is_gzip_enabled', true); - $this->isStripprefixEnabled = data_get($this->application, 'is_stripprefix_enabled', true); - } - } - - public function convertToDatabase() - { - try { - $this->authorize('update', $this->application); - $service = $this->application->service; - $serviceApplication = $this->application; - - // Check if database with same name already exists - if ($service->databases()->where('name', $serviceApplication->name)->exists()) { - throw new \Exception('A database with this name already exists.'); - } - - $redirectParams = collect($this->parameters) - ->except('database_uuid') - ->all(); - DB::transaction(function () use ($service, $serviceApplication) { - $service->databases()->create([ - 'name' => $serviceApplication->name, - 'human_name' => $serviceApplication->human_name, - 'description' => $serviceApplication->description, - 'exclude_from_status' => $serviceApplication->exclude_from_status, - 'is_log_drain_enabled' => $serviceApplication->is_log_drain_enabled, - 'image' => $serviceApplication->image, - 'service_id' => $service->id, - 'is_migrated' => true, - ]); - $serviceApplication->delete(); - }); - - return redirect()->route('project.service.configuration', $redirectParams); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function confirmDomainUsage() - { - $this->forceSaveDomains = true; - $this->showDomainConflictModal = false; - $this->submit(); - } - - public function submit() - { - try { - $this->authorize('update', $this->application); - $this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); - $this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString(); - $domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) { - $domain = trim($domain); - Url::fromString($domain, ['http', 'https']); - - return str($domain)->lower(); - }); - $this->fqdn = $domains->unique()->implode(','); - $warning = sslipDomainWarning($this->fqdn); - if ($warning) { - $this->dispatch('warning', __('warning.sslipdomain')); - } - // Sync to model for domain conflict check (without validation) - $this->application->human_name = $this->humanName; - $this->application->description = $this->description; - $this->application->fqdn = $this->fqdn; - $this->application->image = $this->image; - $this->application->exclude_from_status = $this->excludeFromStatus; - $this->application->is_log_drain_enabled = $this->isLogDrainEnabled; - $this->application->is_gzip_enabled = $this->isGzipEnabled; - $this->application->is_stripprefix_enabled = $this->isStripprefixEnabled; - // Check for domain conflicts if not forcing save - if (! $this->forceSaveDomains) { - $result = checkDomainUsage(resource: $this->application); - if ($result['hasConflicts']) { - $this->domainConflicts = $result['conflicts']; - $this->showDomainConflictModal = true; - - return; - } - } else { - // Reset the force flag after using it - $this->forceSaveDomains = false; - } - - // Check for required port - if (! $this->forceRemovePort) { - $requiredPort = $this->application->getRequiredPort(); - - if ($requiredPort !== null) { - // Check if all FQDNs have a port - $fqdns = str($this->fqdn)->trim()->explode(','); - $missingPort = false; - - foreach ($fqdns as $fqdn) { - $fqdn = trim($fqdn); - if (empty($fqdn)) { - continue; - } - - $port = ServiceApplication::extractPortFromUrl($fqdn); - if ($port === null) { - $missingPort = true; - break; - } - } - - if ($missingPort) { - $this->requiredPort = $requiredPort; - $this->showPortWarningModal = true; - - return; - } - } - } else { - // Reset the force flag after using it - $this->forceRemovePort = false; - } - - $this->validate(); - $this->application->save(); - $this->application->refresh(); - $this->syncData(); - updateCompose($this->application); - if (str($this->application->fqdn)->contains(',')) { - $this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.

Only use multiple domains if you know what you are doing.'); - } else { - ! $warning && $this->dispatch('success', 'Service saved.'); - } - $this->dispatch('generateDockerCompose'); - } catch (\Throwable $e) { - $originalFqdn = $this->application->getOriginal('fqdn'); - if ($originalFqdn !== $this->application->fqdn) { - $this->application->fqdn = $originalFqdn; - $this->syncData(); - } - - return handleError($e, $this); - } - } - - public function render() - { - return view('livewire.project.service.service-application-view', [ - 'checkboxes' => [ - ['id' => 'delete_volumes', 'label' => __('resource.delete_volumes')], - ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], - // ['id' => 'delete_associated_backups_locally', 'label' => 'All backups associated with this Ressource will be permanently deleted from local storage.'], - // ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected S3 Storage.'], - // ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected SFTP Storage.'] - ], - ]); - } -} diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 4db777732..f61790f52 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -551,7 +551,21 @@ function getResourceByUuid(string $uuid, ?int $teamId = null) return null; } $resource = queryResourcesByUuid($uuid); - if (! is_null($resource) && $resource->environment->project->team_id === $teamId) { + if (is_null($resource)) { + return null; + } + + // ServiceDatabase has a different relationship path: service->environment->project->team_id + if ($resource instanceof \App\Models\ServiceDatabase) { + if ($resource->service?->environment?->project?->team_id === $teamId) { + return $resource; + } + + return null; + } + + // Standard resources: environment->project->team_id + if ($resource->environment->project->team_id === $teamId) { return $resource; } @@ -638,6 +652,12 @@ function queryResourcesByUuid(string $uuid) return $clickhouse; } + // Check for ServiceDatabase by its own UUID + $serviceDatabase = ServiceDatabase::whereUuid($uuid)->first(); + if ($serviceDatabase) { + return $serviceDatabase; + } + return $resource; } function generateTagDeployWebhook($tag_name) diff --git a/resources/views/components/service-database/sidebar.blade.php b/resources/views/components/service-database/sidebar.blade.php new file mode 100644 index 000000000..728df3a7b --- /dev/null +++ b/resources/views/components/service-database/sidebar.blade.php @@ -0,0 +1,24 @@ +@props([ + 'parameters', + 'serviceDatabase', + 'isImportSupported' => false, +]) + +
+ + + + General + @if ($serviceDatabase?->isBackupSolutionAvailable() || $serviceDatabase?->is_migrated) + Backups + @endif + @if ($isImportSupported) + Import Backup + @endif +
diff --git a/resources/views/livewire/project/database/backup-executions.blade.php b/resources/views/livewire/project/database/backup-executions.blade.php index c56908f50..b6d88a2fd 100644 --- a/resources/views/livewire/project/database/backup-executions.blade.php +++ b/resources/views/livewire/project/database/backup-executions.blade.php @@ -186,10 +186,13 @@ class="flex flex-col gap-4">
No executions found.
@endforelse - @endisset + +@script + +@endscript diff --git a/resources/views/livewire/project/database/configuration.blade.php b/resources/views/livewire/project/database/configuration.blade.php index 928f27927..7460274e4 100644 --- a/resources/views/livewire/project/database/configuration.blade.php +++ b/resources/views/livewire/project/database/configuration.blade.php @@ -18,9 +18,9 @@ href="{{ route('project.database.persistent-storage', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}">Persistent Storage @can('update', $database) - Import - Backups + Import + Backup @endcan Webhooks @@ -63,7 +63,7 @@ @elseif ($currentRoute === 'project.database.persistent-storage') - @elseif ($currentRoute === 'project.database.import-backups') + @elseif ($currentRoute === 'project.database.import-backup') @elseif ($currentRoute === 'project.database.webhooks') diff --git a/resources/views/livewire/project/database/import.blade.php b/resources/views/livewire/project/database/import.blade.php index 3e8069d7e..666abb3b3 100644 --- a/resources/views/livewire/project/database/import.blade.php +++ b/resources/views/livewire/project/database/import.blade.php @@ -58,9 +58,9 @@ This is a destructive action, existing data will be replaced! - @if (str(data_get($resource, 'status'))->startsWith('running')) + @if (str($resourceStatus)->startsWith('running')) {{-- Restore Command Configuration --}} - @if ($resource->type() === 'standalone-postgresql') + @if ($resourceDbType === 'standalone-postgresql') @if ($dumpAll) @@ -75,7 +75,7 @@
- @elseif ($resource->type() === 'standalone-mysql') + @elseif ($resourceDbType === 'standalone-mysql') @if ($dumpAll) @@ -85,7 +85,7 @@
- @elseif ($resource->type() === 'standalone-mariadb') + @elseif ($resourceDbType === 'standalone-mariadb') @if ($dumpAll) @@ -112,7 +112,7 @@ class="flex-1 p-6 border-2 rounded-sm cursor-pointer transition-all" - @if ($availableS3Storages->count() > 0) + @if (count($availableS3Storages) > 0)
@@ -128,7 +128,7 @@ class="flex-1 p-6 border-2 rounded-sm cursor-pointer transition-all"
{{-- File Restore Section --}} - @can('update', $resource) + @can('update', $this->resource)

Backup File

@@ -139,7 +139,7 @@ class="flex-1 p-6 border-2 rounded-sm cursor-pointer transition-all"
Or
- + @csrf
@@ -168,17 +168,17 @@ class="flex-1 p-6 border-2 rounded-sm cursor-pointer transition-all" @endcan {{-- S3 Restore Section --}} - @if ($availableS3Storages->count() > 0) - @can('update', $resource) + @if (count($availableS3Storages) > 0) + @can('update', $this->resource)

Restore from S3

@foreach ($availableS3Storages as $storage) - @endforeach @@ -226,7 +226,7 @@ class="flex-1 p-6 border-2 rounded-sm cursor-pointer transition-all" Database Restore Output
- +
diff --git a/resources/views/livewire/project/service/configuration.blade.php b/resources/views/livewire/project/service/configuration.blade.php index ec8377deb..35eadeb33 100644 --- a/resources/views/livewire/project/service/configuration.blade.php +++ b/resources/views/livewire/project/service/configuration.blade.php @@ -155,7 +155,7 @@ class="w-4 h-4 dark:text-warning text-coollabs"
@if ($database->isBackupSolutionAvailable() || $database->is_migrated) + href="{{ route('project.service.database.backups', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'service_uuid' => $service->uuid, 'stack_service_uuid' => $database->uuid]) }}"> Backups @endif diff --git a/resources/views/livewire/project/service/database-backups.blade.php b/resources/views/livewire/project/service/database-backups.blade.php new file mode 100644 index 000000000..f98d8c8e1 --- /dev/null +++ b/resources/views/livewire/project/service/database-backups.blade.php @@ -0,0 +1,23 @@ +
+ +
+ +
+ + {{ data_get_str($service, 'name')->limit(10) }} > + {{ data_get_str($serviceDatabase, 'name')->limit(10) }} > Backups | Coolify + +
+

Scheduled Backups

+ @if (filled($serviceDatabase->custom_type) || !$serviceDatabase->is_migrated) + @can('update', $serviceDatabase) + + + + @endcan + @endif +
+ +
+
+
diff --git a/resources/views/livewire/project/service/database.blade.php b/resources/views/livewire/project/service/database.blade.php deleted file mode 100644 index eebe78c3f..000000000 --- a/resources/views/livewire/project/service/database.blade.php +++ /dev/null @@ -1,67 +0,0 @@ -
-
-
- @if ($database->human_name) -

{{ Str::headline($database->human_name) }}

- @else -

{{ Str::headline($database->name) }}

- @endif - Save - @can('update', $database) - - @endcan - @can('delete', $database) - - @endcan -
-
-
- - - -
-
- - -
- @if ($db_url_public) - - @endif -
-

Advanced

-
- - -
-
- - @php - $dbType = $database->databaseType(); - $supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo']; - $isSupported = collect($supportedTypes)->contains(fn($type) => str_contains($dbType, $type)); - @endphp - - @if ($isSupported) - @can('update', $database) -
-

Import Backup

- -
- @endcan - @endif -
diff --git a/resources/views/livewire/project/service/index.blade.php b/resources/views/livewire/project/service/index.blade.php index b102292b9..ad995676c 100644 --- a/resources/views/livewire/project/service/index.blade.php +++ b/resources/views/livewire/project/service/index.blade.php @@ -1,54 +1,257 @@ -
+
-
- - - - General - @if ($serviceDatabase?->isBackupSolutionAvailable() || $serviceDatabase?->is_migrated) - Backups - @endif -
+ @if ($resourceType === 'database') + + @else + + @endif
- @isset($serviceApplication) + @if ($resourceType === 'application') {{ data_get_str($service, 'name')->limit(10) }} > {{ data_get_str($serviceApplication, 'name')->limit(10) }} | Coolify -
- -
- @endisset - @isset($serviceDatabase) +
+
+ @if ($serviceApplication->human_name) +

{{ Str::headline($serviceApplication->human_name) }}

+ @else +

{{ Str::headline($serviceApplication->name) }}

+ @endif + Save + @can('update', $serviceApplication) + + @endcan + @can('delete', $serviceApplication) + + @endcan +
+
+ @if ($requiredPort && !$serviceApplication->serviceType()?->contains(str($serviceApplication->image)->before(':'))) + + This service requires port {{ $requiredPort }} to function correctly. All domains must include this port number (or any other port if you know what you're doing). +

+ Example: http://app.coolify.io:{{ $requiredPort }} +
+ @endif + +
+ + +
+
+ @if (!$serviceApplication->serviceType()?->contains(str($serviceApplication->image)->before(':'))) + @if ($serviceApplication->required_fqdn) + + @else + + @endif + @endif + +
+
+

Advanced

+
+ @if (str($serviceApplication->image)->contains('pocketbase')) + + @else + + @endif + + + +
+
+ + + +
    +
  • Only one service will be accessible at this domain
  • +
  • The routing behavior will be unpredictable
  • +
  • You may experience service disruptions
  • +
  • SSL certificates might not work correctly
  • +
+
+
+ + @if ($showPortWarningModal) +
+ +
+ @endif + @elseif ($resourceType === 'database') {{ data_get_str($service, 'name')->limit(10) }} > {{ data_get_str($serviceDatabase, 'name')->limit(10) }} | Coolify -
- -
- @if ($serviceDatabase?->isBackupSolutionAvailable() || $serviceDatabase?->is_migrated) -
-
-

Scheduled Backups

- @if (filled($serviceDatabase->custom_type) || !$serviceDatabase->is_migrated) - @can('update', $serviceDatabase) - - - - @endcan + @if ($currentRoute === 'project.service.database.import') + + @else +
+
+ @if ($serviceDatabase->human_name) +

{{ Str::headline($serviceDatabase->human_name) }}

+ @else +

{{ Str::headline($serviceDatabase->name) }}

@endif + Save + @can('update', $serviceDatabase) + + @endcan + @can('delete', $serviceDatabase) + + @endcan
- -
+
+
+ + + +
+
+
+

Proxy

+ + @if ($serviceDatabase->is_public) + + Proxy Logs + + + + Logs + + @endif +
+
+ +
+ + @if ($db_url_public) + + @endif +
+
+

Advanced

+
+ + +
+ @endif - @endisset + @endif
diff --git a/resources/views/livewire/project/service/service-application-view.blade.php b/resources/views/livewire/project/service/service-application-view.blade.php deleted file mode 100644 index f04e33817..000000000 --- a/resources/views/livewire/project/service/service-application-view.blade.php +++ /dev/null @@ -1,149 +0,0 @@ -
-
-
- @if ($application->human_name) -

{{ Str::headline($application->human_name) }}

- @else -

{{ Str::headline($application->name) }}

- @endif - Save - @can('update', $application) - - @endcan - @can('delete', $application) - - @endcan -
-
- @if($requiredPort && !$application->serviceType()?->contains(str($application->image)->before(':'))) - - This service requires port {{ $requiredPort }} to function correctly. All domains must include this port number (or any other port if you know what you're doing). -

- Example: http://app.coolify.io:{{ $requiredPort }} -
- @endif - -
- - -
-
- @if (!$application->serviceType()?->contains(str($application->image)->before(':'))) - @if ($application->required_fqdn) - - @else - - @endif - @endif - -
-
-

Advanced

-
- @if (str($application->image)->contains('pocketbase')) - - @else - - @endif - - - -
-
- - - -
    -
  • Only one service will be accessible at this domain
  • -
  • The routing behavior will be unpredictable
  • -
  • You may experience service disruptions
  • -
  • SSL certificates might not work correctly
  • -
-
-
- - @if ($showPortWarningModal) -
- -
- @endif -
diff --git a/routes/web.php b/routes/web.php index 2a9072299..e8c738b71 100644 --- a/routes/web.php +++ b/routes/web.php @@ -29,6 +29,7 @@ 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\Index as ServiceIndex; use App\Livewire\Project\Shared\ExecuteContainerCommand; use App\Livewire\Project\Shared\Logs; @@ -218,7 +219,7 @@ 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-backups', DatabaseConfiguration::class)->name('project.database.import-backups')->middleware('can.update.resource'); + Route::get('/import-backup', DatabaseConfiguration::class)->name('project.database.import-backup')->middleware('can.update.resource'); Route::get('/persistent-storage', DatabaseConfiguration::class)->name('project.database.persistent-storage'); Route::get('/webhooks', DatabaseConfiguration::class)->name('project.database.webhooks'); Route::get('/resource-limits', DatabaseConfiguration::class)->name('project.database.resource-limits'); @@ -243,6 +244,8 @@ Route::get('/tags', ServiceConfiguration::class)->name('project.service.tags'); Route::get('/danger', ServiceConfiguration::class)->name('project.service.danger'); Route::get('/terminal', ExecuteContainerCommand::class)->name('project.service.command')->middleware('can.access.terminal'); + Route::get('/{stack_service_uuid}/backups', ServiceDatabaseBackups::class)->name('project.service.database.backups'); + Route::get('/{stack_service_uuid}/import', ServiceIndex::class)->name('project.service.database.import')->middleware('can.update.resource'); Route::get('/{stack_service_uuid}', ServiceIndex::class)->name('project.service.index'); Route::get('/tasks/{task_uuid}', ScheduledTaskShow::class)->name('project.service.scheduled-tasks'); });