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.
This commit is contained in:
Andras Bacsai 2026-01-02 16:29:48 +01:00
parent 9466ad4a48
commit 796bb3a19d
18 changed files with 1056 additions and 911 deletions

View file

@ -13,7 +13,8 @@ class UploadController extends BaseController
{ {
public function upload(Request $request) 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)) { if (is_null($resource)) {
return response()->json(['error' => 'You do not have permission for this database'], 500); 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(); $save = $receiver->receive();
if ($save->isFinished()) { 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(); $handler = $save->handler();
@ -57,10 +61,10 @@ public function upload(Request $request)
// 'mime_type' => $mime // 'mime_type' => $mime
// ]); // ]);
// } // }
protected function saveFile(UploadedFile $file, $resource) protected function saveFile(UploadedFile $file, string $resourceIdentifier)
{ {
$mime = str_replace('/', '-', $file->getMimeType()); $mime = str_replace('/', '-', $file->getMimeType());
$filePath = "upload/{$resource->uuid}"; $filePath = "upload/{$resourceIdentifier}";
$finalPath = storage_path('app/'.$filePath); $finalPath = storage_path('app/'.$filePath);
$file->move($finalPath, 'restore'); $file->move($finalPath, 'restore');

View file

@ -8,7 +8,6 @@
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
use Spatie\Url\Url;
class BackupEdit extends Component class BackupEdit extends Component
{ {
@ -184,13 +183,14 @@ public function delete($password)
$this->backup->delete(); $this->backup->delete();
if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) { if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
$previousUrl = url()->previous(); $serviceDatabase = $this->backup->database;
$url = Url::fromString($previousUrl);
$url = $url->withoutQueryParameter('selectedBackupId');
$url = $url->withFragment('backups');
$url = $url->getPath()."#{$url->getFragment()}";
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 { } else {
return redirect()->route('project.database.backup.index', $this->parameters); return redirect()->route('project.database.backup.index', $this->parameters);
} }

View file

@ -4,9 +4,11 @@
use App\Models\S3Storage; use App\Models\S3Storage;
use App\Models\Server; use App\Models\Server;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Computed;
use Livewire\Component; use Livewire\Component;
class Import extends Component class Import extends Component
@ -101,11 +103,23 @@ private function validateServerPath(string $path): bool
public bool $unsupported = false; 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; public bool $scpInProgress = false;
@ -121,8 +135,6 @@ private function validateServerPath(string $path): bool
public bool $error = false; public bool $error = false;
public Server $server;
public string $container; public string $container;
public array $importCommands = []; 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='; 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 // S3 Restore properties
public $availableS3Storages = []; public array $availableS3Storages = [];
public ?int $s3StorageId = null; public ?int $s3StorageId = null;
@ -152,6 +164,26 @@ private function validateServerPath(string $path): bool
public ?int $s3FileSize = null; 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() public function getListeners()
{ {
$userId = Auth::id(); $userId = Auth::id();
@ -177,7 +209,7 @@ public function mount()
public function updatedDumpAll($value) public function updatedDumpAll($value)
{ {
$morphClass = $this->resource->getMorphClass(); $morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type // Handle ServiceDatabase by checking the database type
if ($morphClass === \App\Models\ServiceDatabase::class) { if ($morphClass === \App\Models\ServiceDatabase::class) {
$dbType = $this->resource->databaseType(); $dbType = $this->resource->databaseType();
@ -189,7 +221,7 @@ public function updatedDumpAll($value)
$morphClass = 'postgresql'; $morphClass = 'postgresql';
} }
} }
switch ($morphClass) { switch ($morphClass) {
case \App\Models\StandaloneMariadb::class: case \App\Models\StandaloneMariadb::class:
case 'mariadb': case 'mariadb':
@ -242,42 +274,95 @@ public function updatedDumpAll($value)
public function getContainers() public function getContainers()
{ {
$this->containers = collect(); $this->containers = [];
if (! data_get($this->parameters, 'database_uuid')) { $teamId = data_get(auth()->user()->currentTeam(), 'id');
abort(404);
} // Try to find resource by route parameter
$resource = getResourceByUuid($this->parameters['database_uuid'], data_get(auth()->user()->currentTeam(), 'id')); $databaseUuid = data_get($this->parameters, 'database_uuid');
if (is_null($resource)) { $stackServiceUuid = data_get($this->parameters, 'stack_service_uuid');
abort(404);
} $resource = null;
$this->authorize('view', $resource); if ($databaseUuid) {
$this->resource = $resource; // Standalone database route
$this->server = $this->resource->destination->server; $resource = getResourceByUuid($databaseUuid, $teamId);
if (is_null($resource)) {
// Handle ServiceDatabase container naming abort(404);
if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) { }
$this->container = $this->resource->name . '-' . $this->resource->service->uuid; } 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 { } else {
$this->container = $this->resource->uuid; abort(404);
} }
if (str(data_get($this, 'resource.status'))->startsWith('running')) { $this->authorize('view', $resource);
$this->containers->push($this->container);
// 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 ( if (
$this->resource->getMorphClass() === \App\Models\StandaloneRedis::class || $resource->getMorphClass() === \App\Models\StandaloneRedis::class ||
$this->resource->getMorphClass() === \App\Models\StandaloneKeydb::class || $resource->getMorphClass() === \App\Models\StandaloneKeydb::class ||
$this->resource->getMorphClass() === \App\Models\StandaloneDragonfly::class || $resource->getMorphClass() === \App\Models\StandaloneDragonfly::class ||
$this->resource->getMorphClass() === \App\Models\StandaloneClickhouse::class $resource->getMorphClass() === \App\Models\StandaloneClickhouse::class
) { ) {
$this->unsupported = true; $this->unsupported = true;
} }
// Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.) // Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.)
if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) { if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
$dbType = $this->resource->databaseType(); $dbType = $resource->databaseType();
if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') || if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') ||
str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) { str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) {
$this->unsupported = true; $this->unsupported = true;
} }
@ -294,6 +379,12 @@ public function checkFile()
return; return;
} }
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return;
}
try { try {
$escapedPath = escapeshellarg($this->customLocation); $escapedPath = escapeshellarg($this->customLocation);
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false); $result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
@ -319,15 +410,22 @@ public function runImport()
return; return;
} }
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return;
}
try { try {
$this->importRunning = true; $this->importRunning = true;
$this->importCommands = []; $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) // Check if an uploaded file exists first (takes priority over custom location)
if (Storage::exists($backupFileName)) { if (Storage::exists($backupFileName)) {
$path = Storage::path($backupFileName); $path = Storage::path($backupFileName);
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resource->uuid; $tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
instant_scp($path, $tmpPath, $this->server); instant_scp($path, $tmpPath, $this->server);
Storage::delete($backupFileName); Storage::delete($backupFileName);
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}"; $this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
@ -338,7 +436,7 @@ public function runImport()
return; return;
} }
$tmpPath = '/tmp/restore_'.$this->resource->uuid; $tmpPath = '/tmp/restore_'.$this->resourceUuid;
$escapedCustomLocation = escapeshellarg($this->customLocation); $escapedCustomLocation = escapeshellarg($this->customLocation);
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}"; $this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
} else { } else {
@ -348,7 +446,7 @@ public function runImport()
} }
// Copy the restore command to a script file // 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); $restoreCommand = $this->buildRestoreCommand($tmpPath);
@ -388,9 +486,11 @@ public function loadAvailableS3Storages()
try { try {
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description']) $this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
->where('is_usable', true) ->where('is_usable', true)
->get(); ->get()
->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
->toArray();
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->availableS3Storages = collect(); $this->availableS3Storages = [];
} }
} }
@ -493,6 +593,12 @@ public function restoreFromS3()
return; return;
} }
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return;
}
try { try {
$this->importRunning = true; $this->importRunning = true;
@ -526,14 +632,18 @@ public function restoreFromS3()
$fullImageName = "{$helperImage}:{$latestVersion}"; $fullImageName = "{$helperImage}:{$latestVersion}";
// Get the database destination network // 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 // Generate unique names for this operation
$containerName = "s3-restore-{$this->resource->uuid}"; $containerName = "s3-restore-{$this->resourceUuid}";
$helperTmpPath = '/tmp/'.basename($cleanPath); $helperTmpPath = '/tmp/'.basename($cleanPath);
$serverTmpPath = "/tmp/s3-restore-{$this->resource->uuid}-".basename($cleanPath); $serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
$containerTmpPath = "/tmp/restore_{$this->resource->uuid}-".basename($cleanPath); $containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh"; $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
// Prepare all commands in sequence // Prepare all commands in sequence
$commands = []; $commands = [];
@ -609,7 +719,7 @@ public function restoreFromS3()
public function buildRestoreCommand(string $tmpPath): string public function buildRestoreCommand(string $tmpPath): string
{ {
$morphClass = $this->resource->getMorphClass(); $morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type // Handle ServiceDatabase by checking the database type
if ($morphClass === \App\Models\ServiceDatabase::class) { if ($morphClass === \App\Models\ServiceDatabase::class) {
$dbType = $this->resource->databaseType(); $dbType = $this->resource->databaseType();
@ -623,7 +733,7 @@ public function buildRestoreCommand(string $tmpPath): string
$morphClass = 'mongodb'; $morphClass = 'mongodb';
} }
} }
switch ($morphClass) { switch ($morphClass) {
case \App\Models\StandaloneMariadb::class: case \App\Models\StandaloneMariadb::class:
case 'mariadb': case 'mariadb':

View file

@ -1,228 +0,0 @@
<?php
namespace App\Livewire\Project\Service;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Database extends Component
{
use AuthorizesRequests;
public ServiceDatabase $database;
public ?string $db_url_public = null;
public $fileStorages;
public $parameters;
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;
protected $listeners = ['refreshFileStorages'];
protected $rules = [
'humanName' => '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');
}
}
}

View file

@ -0,0 +1,64 @@
<?php
namespace App\Livewire\Project\Service;
use App\Models\Service;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class DatabaseBackups extends Component
{
use AuthorizesRequests;
public ?Service $service = null;
public ?ServiceDatabase $serviceDatabase = null;
public array $parameters;
public array $query;
public bool $isImportSupported = false;
protected $listeners = ['refreshScheduledBackups' => '$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');
}
}

View file

@ -2,12 +2,17 @@
namespace App\Livewire\Project\Service; 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\Service;
use App\Models\ServiceApplication; use App\Models\ServiceApplication;
use App\Models\ServiceDatabase; use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Component; use Livewire\Component;
use Spatie\Url\Url;
class Index extends Component class Index extends Component
{ {
@ -19,6 +24,10 @@ class Index extends Component
public ?ServiceDatabase $serviceDatabase = null; public ?ServiceDatabase $serviceDatabase = null;
public ?string $resourceType = null;
public ?string $currentRoute = null;
public array $parameters; public array $parameters;
public array $query; public array $query;
@ -27,7 +36,67 @@ class Index extends Component
public $s3s; 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() public function mount()
{ {
@ -35,6 +104,7 @@ public function mount()
$this->services = collect([]); $this->services = collect([]);
$this->parameters = get_route_parameters(); $this->parameters = get_route_parameters();
$this->query = request()->query(); $this->query = request()->query();
$this->currentRoute = request()->route()->getName();
$this->service = Service::whereUuid($this->parameters['service_uuid'])->first(); $this->service = Service::whereUuid($this->parameters['service_uuid'])->first();
if (! $this->service) { if (! $this->service) {
return redirect()->route('dashboard'); return redirect()->route('dashboard');
@ -43,7 +113,9 @@ public function mount()
$service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first(); $service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first();
if ($service) { if ($service) {
$this->serviceApplication = $service; $this->serviceApplication = $service;
$this->resourceType = 'application';
$this->serviceApplication->getFilesFromServer(); $this->serviceApplication->getFilesFromServer();
$this->initializeApplicationProperties();
} else { } else {
$this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first(); $this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first();
if (! $this->serviceDatabase) { if (! $this->serviceDatabase) {
@ -53,7 +125,9 @@ public function mount()
'service_uuid' => $this->parameters['service_uuid'], 'service_uuid' => $this->parameters['service_uuid'],
]); ]);
} }
$this->resourceType = 'database';
$this->serviceDatabase->getFilesFromServer(); $this->serviceDatabase->getFilesFromServer();
$this->initializeDatabaseProperties();
} }
$this->s3s = currentTeam()->s3s; $this->s3s = currentTeam()->s3s;
} catch (\Throwable $e) { } 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() public function generateDockerCompose()
{ {
try { 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.<br><br>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() public function render()
{ {
return view('livewire.project.service.index'); return view('livewire.project.service.index');

View file

@ -1,345 +0,0 @@
<?php
namespace App\Livewire\Project\Service;
use App\Models\ServiceApplication;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
class ServiceApplicationView extends Component
{
use AuthorizesRequests;
public ServiceApplication $application;
public $parameters;
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;
#[Validate(['nullable'])]
public ?string $humanName = null;
#[Validate(['nullable'])]
public ?string $description = null;
#[Validate(['nullable'])]
public ?string $fqdn = null;
#[Validate(['string', 'nullable'])]
public ?string $image = null;
#[Validate(['required', 'boolean'])]
public bool $excludeFromStatus = false;
#[Validate(['nullable', 'boolean'])]
public bool $isLogDrainEnabled = false;
#[Validate(['nullable', 'boolean'])]
public bool $isGzipEnabled = false;
#[Validate(['nullable', 'boolean'])]
public bool $isStripprefixEnabled = false;
protected $rules = [
'humanName' => '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.<br><br>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.']
],
]);
}
}

View file

@ -551,7 +551,21 @@ function getResourceByUuid(string $uuid, ?int $teamId = null)
return null; return null;
} }
$resource = queryResourcesByUuid($uuid); $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; return $resource;
} }
@ -638,6 +652,12 @@ function queryResourcesByUuid(string $uuid)
return $clickhouse; return $clickhouse;
} }
// Check for ServiceDatabase by its own UUID
$serviceDatabase = ServiceDatabase::whereUuid($uuid)->first();
if ($serviceDatabase) {
return $serviceDatabase;
}
return $resource; return $resource;
} }
function generateTagDeployWebhook($tag_name) function generateTagDeployWebhook($tag_name)

View file

@ -0,0 +1,24 @@
@props([
'parameters',
'serviceDatabase',
'isImportSupported' => false,
])
<div class="flex flex-col items-start gap-2 min-w-fit">
<a class="menu-item"
class="{{ request()->routeIs('project.service.configuration') ? 'menu-item-active' : '' }}"
{{ wireNavigate() }}
href="{{ route('project.service.configuration', [...$parameters, 'stack_service_uuid' => null]) }}">
<button><- Back</button>
</a>
<a class="menu-item" wire:current.exact="menu-item-active" {{ wireNavigate() }}
href="{{ route('project.service.index', $parameters) }}">General</a>
@if ($serviceDatabase?->isBackupSolutionAvailable() || $serviceDatabase?->is_migrated)
<a class="menu-item" wire:current.exact="menu-item-active" {{ wireNavigate() }}
href="{{ route('project.service.database.backups', $parameters) }}">Backups</a>
@endif
@if ($isImportSupported)
<a class="menu-item" wire:current.exact="menu-item-active"
href="{{ route('project.service.database.import', $parameters) }}">Import Backup</a>
@endif
</div>

View file

@ -186,10 +186,13 @@ class="flex flex-col gap-4">
<div class="p-4 bg-gray-100 dark:bg-coolgray-100 rounded-sm">No executions found.</div> <div class="p-4 bg-gray-100 dark:bg-coolgray-100 rounded-sm">No executions found.</div>
@endforelse @endforelse
</div> </div>
<script>
function download_file(executionId) {
window.open('/download/backup/' + executionId, '_blank');
}
</script>
@endisset @endisset
</div> </div>
@script
<script>
window.download_file = function(executionId) {
window.open('/download/backup/' + executionId, '_blank');
}
</script>
@endscript

View file

@ -18,9 +18,9 @@
href="{{ route('project.database.persistent-storage', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}">Persistent href="{{ route('project.database.persistent-storage', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}">Persistent
Storage</a> Storage</a>
@can('update', $database) @can('update', $database)
<a class='menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active" <a class='menu-item' wire:current.exact="menu-item-active"
href="{{ route('project.database.import-backups', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}">Import href="{{ route('project.database.import-backup', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}">Import
Backups</a> Backup</a>
@endcan @endcan
<a class='menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active" <a class='menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.database.webhooks', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}">Webhooks</a> href="{{ route('project.database.webhooks', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}">Webhooks</a>
@ -63,7 +63,7 @@
<livewire:project.shared.destination :resource="$database" /> <livewire:project.shared.destination :resource="$database" />
@elseif ($currentRoute === 'project.database.persistent-storage') @elseif ($currentRoute === 'project.database.persistent-storage')
<livewire:project.service.storage :resource="$database" /> <livewire:project.service.storage :resource="$database" />
@elseif ($currentRoute === 'project.database.import-backups') @elseif ($currentRoute === 'project.database.import-backup')
<livewire:project.database.import :resource="$database" /> <livewire:project.database.import :resource="$database" />
@elseif ($currentRoute === 'project.database.webhooks') @elseif ($currentRoute === 'project.database.webhooks')
<livewire:project.shared.webhooks :resource="$database" /> <livewire:project.shared.webhooks :resource="$database" />

View file

@ -58,9 +58,9 @@
</svg> </svg>
<span>This is a destructive action, existing data will be replaced!</span> <span>This is a destructive action, existing data will be replaced!</span>
</div> </div>
@if (str(data_get($resource, 'status'))->startsWith('running')) @if (str($resourceStatus)->startsWith('running'))
{{-- Restore Command Configuration --}} {{-- Restore Command Configuration --}}
@if ($resource->type() === 'standalone-postgresql') @if ($resourceDbType === 'standalone-postgresql')
@if ($dumpAll) @if ($dumpAll)
<x-forms.textarea rows="6" readonly label="Custom Import Command" <x-forms.textarea rows="6" readonly label="Custom Import Command"
wire:model='restoreCommandText'></x-forms.textarea> wire:model='restoreCommandText'></x-forms.textarea>
@ -75,7 +75,7 @@
<div class="w-64 pt-2"> <div class="w-64 pt-2">
<x-forms.checkbox label="Backup includes all databases" wire:model.live='dumpAll'></x-forms.checkbox> <x-forms.checkbox label="Backup includes all databases" wire:model.live='dumpAll'></x-forms.checkbox>
</div> </div>
@elseif ($resource->type() === 'standalone-mysql') @elseif ($resourceDbType === 'standalone-mysql')
@if ($dumpAll) @if ($dumpAll)
<x-forms.textarea rows="14" readonly label="Custom Import Command" <x-forms.textarea rows="14" readonly label="Custom Import Command"
wire:model='restoreCommandText'></x-forms.textarea> wire:model='restoreCommandText'></x-forms.textarea>
@ -85,7 +85,7 @@
<div class="w-64 pt-2"> <div class="w-64 pt-2">
<x-forms.checkbox label="Backup includes all databases" wire:model.live='dumpAll'></x-forms.checkbox> <x-forms.checkbox label="Backup includes all databases" wire:model.live='dumpAll'></x-forms.checkbox>
</div> </div>
@elseif ($resource->type() === 'standalone-mariadb') @elseif ($resourceDbType === 'standalone-mariadb')
@if ($dumpAll) @if ($dumpAll)
<x-forms.textarea rows="14" readonly label="Custom Import Command" <x-forms.textarea rows="14" readonly label="Custom Import Command"
wire:model='restoreCommandText'></x-forms.textarea> wire:model='restoreCommandText'></x-forms.textarea>
@ -112,7 +112,7 @@ class="flex-1 p-6 border-2 rounded-sm cursor-pointer transition-all"
</div> </div>
</div> </div>
@if ($availableS3Storages->count() > 0) @if (count($availableS3Storages) > 0)
<div @click="restoreType = 's3'" <div @click="restoreType = 's3'"
class="flex-1 p-6 border-2 rounded-sm cursor-pointer transition-all" class="flex-1 p-6 border-2 rounded-sm cursor-pointer transition-all"
:class="restoreType === 's3' ? 'border-warning bg-warning/10' : 'border-neutral-200 dark:border-neutral-800 hover:border-warning/50'"> :class="restoreType === 's3' ? 'border-warning bg-warning/10' : 'border-neutral-200 dark:border-neutral-800 hover:border-warning/50'">
@ -128,7 +128,7 @@ class="flex-1 p-6 border-2 rounded-sm cursor-pointer transition-all"
</div> </div>
{{-- File Restore Section --}} {{-- File Restore Section --}}
@can('update', $resource) @can('update', $this->resource)
<div x-show="restoreType === 'file'" class="pt-6"> <div x-show="restoreType === 'file'" class="pt-6">
<h3>Backup File</h3> <h3>Backup File</h3>
<form class="flex gap-2 items-end pt-2"> <form class="flex gap-2 items-end pt-2">
@ -139,7 +139,7 @@ class="flex-1 p-6 border-2 rounded-sm cursor-pointer transition-all"
<div class="pt-2 text-center text-xl font-bold"> <div class="pt-2 text-center text-xl font-bold">
Or Or
</div> </div>
<form action="/upload/backup/{{ $resource->uuid }}" class="dropzone" id="my-dropzone" wire:ignore> <form action="/upload/backup/{{ $resourceUuid }}" class="dropzone" id="my-dropzone" wire:ignore>
@csrf @csrf
</form> </form>
<div x-show="isUploading"> <div x-show="isUploading">
@ -168,17 +168,17 @@ class="flex-1 p-6 border-2 rounded-sm cursor-pointer transition-all"
@endcan @endcan
{{-- S3 Restore Section --}} {{-- S3 Restore Section --}}
@if ($availableS3Storages->count() > 0) @if (count($availableS3Storages) > 0)
@can('update', $resource) @can('update', $this->resource)
<div x-show="restoreType === 's3'" class="pt-6"> <div x-show="restoreType === 's3'" class="pt-6">
<h3>Restore from S3</h3> <h3>Restore from S3</h3>
<div class="flex flex-col gap-2 pt-2"> <div class="flex flex-col gap-2 pt-2">
<x-forms.select label="S3 Storage" wire:model.live="s3StorageId"> <x-forms.select label="S3 Storage" wire:model.live="s3StorageId">
<option value="">Select S3 Storage</option> <option value="">Select S3 Storage</option>
@foreach ($availableS3Storages as $storage) @foreach ($availableS3Storages as $storage)
<option value="{{ $storage->id }}">{{ $storage->name }} <option value="{{ $storage['id'] }}">{{ $storage['name'] }}
@if ($storage->description) @if ($storage['description'])
- {{ $storage->description }} - {{ $storage['description'] }}
@endif @endif
</option> </option>
@endforeach @endforeach
@ -226,7 +226,7 @@ class="flex-1 p-6 border-2 rounded-sm cursor-pointer transition-all"
<x-slot:title>Database Restore Output</x-slot:title> <x-slot:title>Database Restore Output</x-slot:title>
<x-slot:content> <x-slot:content>
<div wire:ignore> <div wire:ignore>
<livewire:activity-monitor wire:key="database-restore-{{ $resource->uuid }}" header="Logs" fullHeight /> <livewire:activity-monitor wire:key="database-restore-{{ $resourceUuid }}" header="Logs" fullHeight />
</div> </div>
</x-slot:content> </x-slot:content>
</x-slide-over> </x-slide-over>

View file

@ -155,7 +155,7 @@ class="w-4 h-4 dark:text-warning text-coollabs"
<div class="flex items-center px-4"> <div class="flex items-center px-4">
@if ($database->isBackupSolutionAvailable() || $database->is_migrated) @if ($database->isBackupSolutionAvailable() || $database->is_migrated)
<a class="mx-4 text-xs font-bold hover:underline" {{ wireNavigate() }} <a class="mx-4 text-xs font-bold hover:underline" {{ wireNavigate() }}
href="{{ route('project.service.index', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'service_uuid' => $service->uuid, 'stack_service_uuid' => $database->uuid]) }}#backups"> href="{{ route('project.service.database.backups', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'service_uuid' => $service->uuid, 'stack_service_uuid' => $database->uuid]) }}">
Backups Backups
</a> </a>
@endif @endif

View file

@ -0,0 +1,23 @@
<div>
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="$query" />
<div class="flex flex-col h-full gap-8 sm:flex-row">
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase" :isImportSupported="$isImportSupported" />
<div class="w-full">
<x-slot:title>
{{ data_get_str($service, 'name')->limit(10) }} >
{{ data_get_str($serviceDatabase, 'name')->limit(10) }} > Backups | Coolify
</x-slot>
<div class="flex gap-2">
<h2 class="pb-4">Scheduled Backups</h2>
@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" />
</x-modal-input>
@endcan
@endif
</div>
<livewire:project.database.scheduled-backups :database="$serviceDatabase" />
</div>
</div>
</div>

View file

@ -1,67 +0,0 @@
<div>
<form wire:submit='submit'>
<div class="flex items-center gap-2 pb-4">
@if ($database->human_name)
<h2>{{ Str::headline($database->human_name) }}</h2>
@else
<h2>{{ Str::headline($database->name) }}</h2>
@endif
<x-forms.button canGate="update" :canResource="$database" type="submit">Save</x-forms.button>
@can('update', $database)
<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($database->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Database Name below"
shortConfirmationLabel="Service Database Name" />
@endcan
@can('delete', $database)
<x-modal-confirmation title="Confirm Service Database Deletion?" buttonTitle="Delete" isErrorButton
submitAction="delete" :actions="['The selected service database container will be stopped and permanently deleted.']" confirmationText="{{ Str::headline($database->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Database Name below"
shortConfirmationLabel="Service Database Name" />
@endcan
</div>
<div class="flex flex-col gap-2">
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$database" label="Name" id="humanName" placeholder="Name"></x-forms.input>
<x-forms.input canGate="update" :canResource="$database" label="Description" id="description"></x-forms.input>
<x-forms.input canGate="update" :canResource="$database" 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="flex items-end gap-2">
<x-forms.input canGate="update" :canResource="$database" placeholder="5432" disabled="{{ $database->is_public }}" id="publicPort"
label="Public Port" />
<x-forms.checkbox canGate="update" :canResource="$database" instantSave id="isPublic" label="Make it publicly available" />
</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>
<h3 class="pt-2">Advanced</h3>
<div class="w-96">
<x-forms.checkbox canGate="update" :canResource="$database" instantSave="instantSaveExclude" label="Exclude from service status"
helper="If you do not need to monitor this resource, enable. Useful if this service is optional."
id="excludeFromStatus"></x-forms.checkbox>
<x-forms.checkbox canGate="update" :canResource="$database" helper="Drain logs to your configured log drain endpoint in your Server settings."
instantSave="instantSaveLogDrain" id="isLogDrainEnabled" label="Drain Logs" />
</div>
</form>
@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)
<div class="pt-6">
<h3 class="pb-4">Import Backup</h3>
<livewire:project.database.import :resource="$database" />
</div>
@endcan
@endif
</div>

View file

@ -1,54 +1,257 @@
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }"> <div>
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="$query" /> <livewire:project.service.heading :service="$service" :parameters="$parameters" :query="$query" />
<div class="flex flex-col h-full gap-8 sm:flex-row"> <div class="flex flex-col h-full gap-8 sm:flex-row">
<div class="flex flex-col items-start gap-2 min-w-fit"> @if ($resourceType === 'database')
<a class="menu-item" <x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase" :isImportSupported="$isImportSupported" />
class="{{ request()->routeIs('project.service.configuration') ? 'menu-item-active' : '' }}" {{ wireNavigate() }} @else
href="{{ route('project.service.configuration', [...$parameters, 'stack_service_uuid' => null]) }}"> <div class="flex flex-col items-start gap-2 min-w-fit">
<button><- Back</button> <a class="menu-item"
</a> class="{{ request()->routeIs('project.service.configuration') ? 'menu-item-active' : '' }}"
<a class="menu-item" :class="activeTab === 'general' && 'menu-item-active'" {{ wireNavigate() }}
@click.prevent="activeTab = 'general'; window.location.hash = 'general'; if(window.location.search) window.location.search = ''" href="{{ route('project.service.configuration', [...$parameters, 'stack_service_uuid' => null]) }}">
href="#">General</a> <button><- Back</button>
@if ($serviceDatabase?->isBackupSolutionAvailable() || $serviceDatabase?->is_migrated) </a>
<a :class="activeTab === 'backups' && 'menu-item-active'" class="menu-item" <a class="menu-item menu-item-active" href="#">General</a>
@click.prevent="activeTab = 'backups'; window.location.hash = 'backups'" href="#backups">Backups</a> </div>
@endif @endif
</div>
<div class="w-full"> <div class="w-full">
@isset($serviceApplication) @if ($resourceType === 'application')
<x-slot:title> <x-slot:title>
{{ data_get_str($service, 'name')->limit(10) }} > {{ data_get_str($service, 'name')->limit(10) }} >
{{ data_get_str($serviceApplication, 'name')->limit(10) }} | Coolify {{ data_get_str($serviceApplication, 'name')->limit(10) }} | Coolify
</x-slot> </x-slot>
<div x-cloak x-show="activeTab === 'general'" class="h-full"> <form wire:submit='submitApplication'>
<livewire:project.service.service-application-view :application="$serviceApplication" /> <div class="flex items-center gap-2 pb-4">
</div> @if ($serviceApplication->human_name)
@endisset <h2>{{ Str::headline($serviceApplication->human_name) }}</h2>
@isset($serviceDatabase) @else
<h2>{{ Str::headline($serviceApplication->name) }}</h2>
@endif
<x-forms.button canGate="update" :canResource="$serviceApplication" type="submit">Save</x-forms.button>
@can('update', $serviceApplication)
<x-modal-confirmation wire:click="convertToDatabase" title="Convert to Database"
buttonTitle="Convert to Database" submitAction="convertToDatabase" :actions="['The selected resource will be converted to a service database.']"
confirmationText="{{ Str::headline($serviceApplication->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Application Name below"
shortConfirmationLabel="Service Application Name" />
@endcan
@can('delete', $serviceApplication)
<x-modal-confirmation title="Confirm Service Application Deletion?" buttonTitle="Delete" isErrorButton
submitAction="deleteApplication" :actions="['The selected service application container will be stopped and permanently deleted.']"
confirmationText="{{ Str::headline($serviceApplication->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Application Name below"
shortConfirmationLabel="Service Application Name" />
@endcan
</div>
<div class="flex flex-col gap-2">
@if ($requiredPort && !$serviceApplication->serviceType()?->contains(str($serviceApplication->image)->before(':')))
<x-callout type="warning" title="Required Port: {{ $requiredPort }}" class="mb-2">
This service requires port <strong>{{ $requiredPort }}</strong> to function correctly. All domains must include this port number (or any other port if you know what you're doing).
<br><br>
<strong>Example:</strong> http://app.coolify.io:{{ $requiredPort }}
</x-callout>
@endif
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$serviceApplication" label="Name" id="humanName"
placeholder="Human readable name"></x-forms.input>
<x-forms.input canGate="update" :canResource="$serviceApplication" label="Description"
id="description"></x-forms.input>
</div>
<div class="flex gap-2">
@if (!$serviceApplication->serviceType()?->contains(str($serviceApplication->image)->before(':')))
@if ($serviceApplication->required_fqdn)
<x-forms.input canGate="update" :canResource="$serviceApplication" required placeholder="https://app.coolify.io"
label="Domains" id="fqdn"
helper="You can specify one domain with path or more with comma. You can specify a port to bind the domain to.<br><br><span class='text-helper'>Example</span><br>- http://app.coolify.io,https://cloud.coolify.io/dashboard<br>- http://app.coolify.io/api/v3<br>- http://app.coolify.io:3000 -> app.coolify.io will point to port 3000 inside the container. "></x-forms.input>
@else
<x-forms.input canGate="update" :canResource="$serviceApplication" placeholder="https://app.coolify.io"
label="Domains" id="fqdn"
helper="You can specify one domain with path or more with comma. You can specify a port to bind the domain to.<br><br><span class='text-helper'>Example</span><br>- http://app.coolify.io,https://cloud.coolify.io/dashboard<br>- http://app.coolify.io/api/v3<br>- http://app.coolify.io:3000 -> app.coolify.io will point to port 3000 inside the container. "></x-forms.input>
@endif
@endif
<x-forms.input canGate="update" :canResource="$serviceApplication"
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>
<h3 class="py-2 pt-4">Advanced</h3>
<div class="w-96 flex flex-col gap-1">
@if (str($serviceApplication->image)->contains('pocketbase'))
<x-forms.checkbox canGate="update" :canResource="$serviceApplication" instantSave="instantSaveApplicationSettings" id="isGzipEnabled"
label="Enable Gzip Compression"
helper="Pocketbase does not need gzip compression, otherwise SSE will not work." disabled />
@else
<x-forms.checkbox canGate="update" :canResource="$serviceApplication" instantSave="instantSaveApplicationSettings" id="isGzipEnabled"
label="Enable Gzip Compression"
helper="You can disable gzip compression if you want. Some services are compressing data by default. In this case, you do not need this." />
@endif
<x-forms.checkbox canGate="update" :canResource="$serviceApplication" instantSave="instantSaveApplicationSettings" id="isStripprefixEnabled"
label="Strip Prefixes"
helper="Strip Prefix is used to remove prefixes from paths. Like /api/ to /api." />
<x-forms.checkbox canGate="update" :canResource="$serviceApplication" instantSave="instantSaveApplicationSettings" label="Exclude from service status"
helper="If you do not need to monitor this resource, enable. Useful if this service is optional."
id="excludeFromStatus"></x-forms.checkbox>
<x-forms.checkbox canGate="update" :canResource="$serviceApplication"
helper="Drain logs to your configured log drain endpoint in your Server settings."
instantSave="instantSaveApplicationAdvanced" id="isLogDrainEnabled" label="Drain Logs" />
</div>
</form>
<x-domain-conflict-modal
:conflicts="$domainConflicts"
:showModal="$showDomainConflictModal"
confirmAction="confirmDomainUsage">
<x-slot:consequences>
<ul class="mt-2 ml-4 list-disc">
<li>Only one service will be accessible at this domain</li>
<li>The routing behavior will be unpredictable</li>
<li>You may experience service disruptions</li>
<li>SSL certificates might not work correctly</li>
</ul>
</x-slot:consequences>
</x-domain-conflict-modal>
@if ($showPortWarningModal)
<div x-data="{ modalOpen: true }" x-init="$nextTick(() => { modalOpen = true })"
@keydown.escape.window="modalOpen = false; $wire.call('cancelRemovePort')"
:class="{ 'z-40': modalOpen }" class="relative w-auto h-auto">
<template x-teleport="body">
<div x-show="modalOpen"
class="fixed top-0 lg:pt-10 left-0 z-99 flex items-start justify-center w-screen h-screen" x-cloak>
<div x-show="modalOpen" class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative w-full py-6 border rounded-sm min-w-full lg:min-w-[36rem] max-w-[48rem] bg-neutral-100 border-neutral-400 dark:bg-base px-7 dark:border-coolgray-300">
<div class="flex justify-between items-center pb-3">
<h2 class="pr-8 font-bold">Remove Required Port?</h2>
<button @click="modalOpen = false; $wire.call('cancelRemovePort')"
class="flex absolute top-2 right-2 justify-center items-center w-8 h-8 rounded-full dark:text-white hover:bg-coolgray-300">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="relative w-auto">
<x-callout type="warning" title="Port Requirement Warning" class="mb-4">
This service requires port <strong>{{ $requiredPort }}</strong> to function correctly.
One or more of your domains are missing a port number.
</x-callout>
<x-callout type="danger" title="What will happen if you continue?" class="mb-4">
<ul class="mt-2 ml-4 list-disc">
<li>The service may become unreachable</li>
<li>The proxy may not be able to route traffic correctly</li>
<li>Environment variables may not be generated properly</li>
<li>The service may fail to start or function</li>
</ul>
</x-callout>
<div class="flex flex-wrap gap-2 justify-between mt-4">
<x-forms.button @click="modalOpen = false; $wire.call('cancelRemovePort')"
class="w-auto dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
Cancel - Keep Port
</x-forms.button>
<x-forms.button wire:click="confirmRemovePort" @click="modalOpen = false" class="w-auto"
isError>
I understand, remove port anyway
</x-forms.button>
</div>
</div>
</div>
</div>
</template>
</div>
@endif
@elseif ($resourceType === 'database')
<x-slot:title> <x-slot:title>
{{ data_get_str($service, 'name')->limit(10) }} > {{ data_get_str($service, 'name')->limit(10) }} >
{{ data_get_str($serviceDatabase, 'name')->limit(10) }} | Coolify {{ data_get_str($serviceDatabase, 'name')->limit(10) }} | Coolify
</x-slot> </x-slot>
<div x-cloak x-show="activeTab === 'general'" class="h-full"> @if ($currentRoute === 'project.service.database.import')
<livewire:project.service.database :database="$serviceDatabase" /> <livewire:project.database.import :resource="$serviceDatabase" :key="'import-' . $serviceDatabase->uuid" />
</div> @else
@if ($serviceDatabase?->isBackupSolutionAvailable() || $serviceDatabase?->is_migrated) <form wire:submit='submitDatabase'>
<div x-cloak x-show="activeTab === 'backups'"> <div class="flex items-center gap-2 pb-4">
<div class="flex gap-2"> @if ($serviceDatabase->human_name)
<h2 class="pb-4">Scheduled Backups</h2> <h2>{{ Str::headline($serviceDatabase->human_name) }}</h2>
@if (filled($serviceDatabase->custom_type) || !$serviceDatabase->is_migrated) @else
@can('update', $serviceDatabase) <h2>{{ Str::headline($serviceDatabase->name) }}</h2>
<x-modal-input buttonTitle="+ Add" title="New Scheduled Backup">
<livewire:project.database.create-scheduled-backup :database="$serviceDatabase" />
</x-modal-input>
@endcan
@endif @endif
<x-forms.button canGate="update" :canResource="$serviceDatabase" type="submit">Save</x-forms.button>
@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" />
@endcan
@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>
<livewire:project.database.scheduled-backups :database="$serviceDatabase" /> <div class="flex flex-col gap-2">
</div> <div class="flex gap-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 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="flex flex-col gap-2">
<div class="flex items-center gap-2 py-2">
<h3>Proxy</h3>
<x-loading wire:loading wire:target="instantSave" />
@if ($serviceDatabase->is_public)
<x-slide-over fullScreen>
<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="slideOverOpen=true">Logs</x-forms.button>
</x-slide-over>
@endif
</div>
<div class="flex flex-col gap-2 w-64">
<x-forms.checkbox canGate="update" :canResource="$serviceDatabase" instantSave id="isPublic"
label="Make it publicly available" />
</div>
<x-forms.input canGate="update" :canResource="$serviceDatabase" placeholder="5432"
disabled="{{ $serviceDatabase->is_public }}" id="publicPort" label="Public Port" />
@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>
<h3 class="pt-2">Advanced</h3>
<div class="w-96">
<x-forms.checkbox canGate="update" :canResource="$serviceDatabase" instantSave="instantSaveExclude"
label="Exclude from service status"
helper="If you do not need to monitor this resource, enable. Useful if this service is optional."
id="excludeFromStatus"></x-forms.checkbox>
<x-forms.checkbox canGate="update" :canResource="$serviceDatabase"
helper="Drain logs to your configured log drain endpoint in your Server settings."
instantSave="instantSaveLogDrain" id="isLogDrainEnabled" label="Drain Logs" />
</div>
</form>
@endif @endif
@endisset @endif
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,149 +0,0 @@
<div>
<form wire:submit='submit'>
<div class="flex items-center gap-2 pb-4">
@if ($application->human_name)
<h2>{{ Str::headline($application->human_name) }}</h2>
@else
<h2>{{ Str::headline($application->name) }}</h2>
@endif
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
@can('update', $application)
<x-modal-confirmation wire:click="convertToDatabase" title="Convert to Database"
buttonTitle="Convert to Database" submitAction="convertToDatabase" :actions="['The selected resource will be converted to a service database.']"
confirmationText="{{ Str::headline($application->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Application Name below"
shortConfirmationLabel="Service Application Name" />
@endcan
@can('delete', $application)
<x-modal-confirmation title="Confirm Service Application Deletion?" buttonTitle="Delete" isErrorButton
submitAction="delete" :actions="['The selected service application container will be stopped and permanently deleted.']" confirmationText="{{ Str::headline($application->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Application Name below"
shortConfirmationLabel="Service Application Name" />
@endcan
</div>
<div class="flex flex-col gap-2">
@if($requiredPort && !$application->serviceType()?->contains(str($application->image)->before(':')))
<x-callout type="warning" title="Required Port: {{ $requiredPort }}" class="mb-2">
This service requires port <strong>{{ $requiredPort }}</strong> to function correctly. All domains must include this port number (or any other port if you know what you're doing).
<br><br>
<strong>Example:</strong> http://app.coolify.io:{{ $requiredPort }}
</x-callout>
@endif
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$application" label="Name" id="humanName"
placeholder="Human readable name"></x-forms.input>
<x-forms.input canGate="update" :canResource="$application" label="Description"
id="description"></x-forms.input>
</div>
<div class="flex gap-2">
@if (!$application->serviceType()?->contains(str($application->image)->before(':')))
@if ($application->required_fqdn)
<x-forms.input canGate="update" :canResource="$application" required placeholder="https://app.coolify.io"
label="Domains" id="fqdn"
helper="You can specify one domain with path or more with comma. You can specify a port to bind the domain to.<br><br><span class='text-helper'>Example</span><br>- http://app.coolify.io,https://cloud.coolify.io/dashboard<br>- http://app.coolify.io/api/v3<br>- http://app.coolify.io:3000 -> app.coolify.io will point to port 3000 inside the container. "></x-forms.input>
@else
<x-forms.input canGate="update" :canResource="$application" placeholder="https://app.coolify.io"
label="Domains" id="fqdn"
helper="You can specify one domain with path or more with comma. You can specify a port to bind the domain to.<br><br><span class='text-helper'>Example</span><br>- http://app.coolify.io,https://cloud.coolify.io/dashboard<br>- http://app.coolify.io/api/v3<br>- http://app.coolify.io:3000 -> app.coolify.io will point to port 3000 inside the container. "></x-forms.input>
@endif
@endif
<x-forms.input canGate="update" :canResource="$application"
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>
<h3 class="py-2 pt-4">Advanced</h3>
<div class="w-96 flex flex-col gap-1">
@if (str($application->image)->contains('pocketbase'))
<x-forms.checkbox canGate="update" :canResource="$application" instantSave="instantSaveSettings" id="isGzipEnabled"
label="Enable Gzip Compression"
helper="Pocketbase does not need gzip compression, otherwise SSE will not work." disabled />
@else
<x-forms.checkbox canGate="update" :canResource="$application" instantSave="instantSaveSettings" id="isGzipEnabled"
label="Enable Gzip Compression"
helper="You can disable gzip compression if you want. Some services are compressing data by default. In this case, you do not need this." />
@endif
<x-forms.checkbox canGate="update" :canResource="$application" instantSave="instantSaveSettings" id="isStripprefixEnabled"
label="Strip Prefixes"
helper="Strip Prefix is used to remove prefixes from paths. Like /api/ to /api." />
<x-forms.checkbox canGate="update" :canResource="$application" instantSave="instantSaveSettings" label="Exclude from service status"
helper="If you do not need to monitor this resource, enable. Useful if this service is optional."
id="excludeFromStatus"></x-forms.checkbox>
<x-forms.checkbox canGate="update" :canResource="$application"
helper="Drain logs to your configured log drain endpoint in your Server settings."
instantSave="instantSaveAdvanced" id="isLogDrainEnabled" label="Drain Logs" />
</div>
</form>
<x-domain-conflict-modal
:conflicts="$domainConflicts"
:showModal="$showDomainConflictModal"
confirmAction="confirmDomainUsage">
<x-slot:consequences>
<ul class="mt-2 ml-4 list-disc">
<li>Only one service will be accessible at this domain</li>
<li>The routing behavior will be unpredictable</li>
<li>You may experience service disruptions</li>
<li>SSL certificates might not work correctly</li>
</ul>
</x-slot:consequences>
</x-domain-conflict-modal>
@if ($showPortWarningModal)
<div x-data="{ modalOpen: true }" x-init="$nextTick(() => { modalOpen = true })"
@keydown.escape.window="modalOpen = false; $wire.call('cancelRemovePort')"
:class="{ 'z-40': modalOpen }" class="relative w-auto h-auto">
<template x-teleport="body">
<div x-show="modalOpen"
class="fixed top-0 lg:pt-10 left-0 z-99 flex items-start justify-center w-screen h-screen" x-cloak>
<div x-show="modalOpen" class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative w-full py-6 border rounded-sm min-w-full lg:min-w-[36rem] max-w-[48rem] bg-neutral-100 border-neutral-400 dark:bg-base px-7 dark:border-coolgray-300">
<div class="flex justify-between items-center pb-3">
<h2 class="pr-8 font-bold">Remove Required Port?</h2>
<button @click="modalOpen = false; $wire.call('cancelRemovePort')"
class="flex absolute top-2 right-2 justify-center items-center w-8 h-8 rounded-full dark:text-white hover:bg-coolgray-300">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="relative w-auto">
<x-callout type="warning" title="Port Requirement Warning" class="mb-4">
This service requires port <strong>{{ $requiredPort }}</strong> to function correctly.
One or more of your domains are missing a port number.
</x-callout>
<x-callout type="danger" title="What will happen if you continue?" class="mb-4">
<ul class="mt-2 ml-4 list-disc">
<li>The service may become unreachable</li>
<li>The proxy may not be able to route traffic correctly</li>
<li>Environment variables may not be generated properly</li>
<li>The service may fail to start or function</li>
</ul>
</x-callout>
<div class="flex flex-wrap gap-2 justify-between mt-4">
<x-forms.button @click="modalOpen = false; $wire.call('cancelRemovePort')"
class="w-auto dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
Cancel - Keep Port
</x-forms.button>
<x-forms.button wire:click="confirmRemovePort" @click="modalOpen = false" class="w-auto"
isError>
I understand, remove port anyway
</x-forms.button>
</div>
</div>
</div>
</div>
</template>
</div>
@endif
</div>

View file

@ -29,6 +29,7 @@
use App\Livewire\Project\Resource\Create as ResourceCreate; use App\Livewire\Project\Resource\Create as ResourceCreate;
use App\Livewire\Project\Resource\Index as ResourceIndex; use App\Livewire\Project\Resource\Index as ResourceIndex;
use App\Livewire\Project\Service\Configuration as ServiceConfiguration; use App\Livewire\Project\Service\Configuration as ServiceConfiguration;
use App\Livewire\Project\Service\DatabaseBackups as ServiceDatabaseBackups;
use App\Livewire\Project\Service\Index as ServiceIndex; use App\Livewire\Project\Service\Index as ServiceIndex;
use App\Livewire\Project\Shared\ExecuteContainerCommand; use App\Livewire\Project\Shared\ExecuteContainerCommand;
use App\Livewire\Project\Shared\Logs; use App\Livewire\Project\Shared\Logs;
@ -218,7 +219,7 @@
Route::get('/', DatabaseConfiguration::class)->name('project.database.configuration'); Route::get('/', DatabaseConfiguration::class)->name('project.database.configuration');
Route::get('/environment-variables', DatabaseConfiguration::class)->name('project.database.environment-variables'); Route::get('/environment-variables', DatabaseConfiguration::class)->name('project.database.environment-variables');
Route::get('/servers', DatabaseConfiguration::class)->name('project.database.servers'); Route::get('/servers', DatabaseConfiguration::class)->name('project.database.servers');
Route::get('/import-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('/persistent-storage', DatabaseConfiguration::class)->name('project.database.persistent-storage');
Route::get('/webhooks', DatabaseConfiguration::class)->name('project.database.webhooks'); Route::get('/webhooks', DatabaseConfiguration::class)->name('project.database.webhooks');
Route::get('/resource-limits', DatabaseConfiguration::class)->name('project.database.resource-limits'); 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('/tags', ServiceConfiguration::class)->name('project.service.tags');
Route::get('/danger', ServiceConfiguration::class)->name('project.service.danger'); Route::get('/danger', ServiceConfiguration::class)->name('project.service.danger');
Route::get('/terminal', ExecuteContainerCommand::class)->name('project.service.command')->middleware('can.access.terminal'); 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('/{stack_service_uuid}', ServiceIndex::class)->name('project.service.index');
Route::get('/tasks/{task_uuid}', ScheduledTaskShow::class)->name('project.service.scheduled-tasks'); Route::get('/tasks/{task_uuid}', ScheduledTaskShow::class)->name('project.service.scheduled-tasks');
}); });