diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index 7155f9a0a..3ed435049 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -3,50 +3,40 @@ namespace App\Actions\Application; use App\Models\Application; +use App\Actions\Server\CleanupDocker; use Lorisleiva\Actions\Concerns\AsAction; class StopApplication { use AsAction; - public function handle(Application $application, bool $previewDeployments = false) + public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true) { - if ($application->destination->server->isSwarm()) { - instant_remote_process(["docker stack rm {$application->uuid}"], $application->destination->server); - - return; - } - - $servers = collect([]); - $servers->push($application->destination->server); - $application->additional_servers->map(function ($server) use ($servers) { - $servers->push($server); - }); - foreach ($servers as $server) { - if (! $server->isFunctional()) { + try { + $server = $application->destination->server; + if (!$server->isFunctional()) { return 'Server is not functional'; } - if ($previewDeployments) { - $containers = getCurrentApplicationContainerStatus($server, $application->id, includePullrequests: true); - } else { - $containers = getCurrentApplicationContainerStatus($server, $application->id, 0); - } - if ($containers->count() > 0) { - foreach ($containers as $container) { - $containerName = data_get($container, 'Names'); - if ($containerName) { - instant_remote_process(command: ["docker stop --time=30 $containerName"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm $containerName"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm -f {$containerName}"], server: $server, throwError: false); - } - } + ray('Stopping application: ' . $application->name); + + if ($server->isSwarm()) { + instant_remote_process(["docker stack rm {$application->uuid}"], $server); + return; } + + $containersToStop = $application->getContainersToStop($previewDeployments); + $application->stopContainers($containersToStop, $server); + if ($application->build_pack === 'dockercompose') { - // remove network - $uuid = $application->uuid; - instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false); - instant_remote_process(["docker network rm {$uuid}"], $server, false); + $application->delete_connected_networks($application->uuid); } + + if ($dockerCleanup) { + CleanupDocker::run($server, true); + } + } catch (\Exception $e) { + ray($e->getMessage()); + return $e->getMessage(); } } } diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php index d562ec56f..0f074bea9 100644 --- a/app/Actions/Database/StopDatabase.php +++ b/app/Actions/Database/StopDatabase.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Server\CleanupDocker; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -10,25 +11,65 @@ use App\Models\StandaloneMongodb; use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use Illuminate\Support\Facades\Process; use Lorisleiva\Actions\Concerns\AsAction; class StopDatabase { use AsAction; - public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database) + public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $isDeleteOperation = false, bool $dockerCleanup = true) { $server = $database->destination->server; if (! $server->isFunctional()) { return 'Server is not functional'; } - instant_remote_process(command: ["docker stop --time=30 $database->uuid"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm $database->uuid"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm -f $database->uuid"], server: $server, throwError: false); + $this->stopContainer($database, $database->uuid, 300); + if (! $isDeleteOperation) { + if ($dockerCleanup) { + CleanupDocker::run($server, true); + } + } if ($database->is_public) { StopDatabaseProxy::run($database); } + + return 'Database stopped successfully'; + } + + private function stopContainer($database, string $containerName, int $timeout = 300): void + { + $server = $database->destination->server; + + $process = Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + + $startTime = time(); + while ($process->running()) { + if (time() - $startTime >= $timeout) { + $this->forceStopContainer($containerName, $server); + break; + } + usleep(100000); + } + + $this->removeContainer($containerName, $server); + } + + private function forceStopContainer(string $containerName, $server): void + { + instant_remote_process(command: ["docker kill $containerName"], server: $server, throwError: false); + } + + private function removeContainer(string $containerName, $server): void + { + instant_remote_process(command: ["docker rm -f $containerName"], server: $server, throwError: false); + } + + private function deleteConnectedNetworks($uuid, $server) + { + instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false); + instant_remote_process(["docker network rm {$uuid}"], $server, false); } } diff --git a/app/Actions/Service/DeleteService.php b/app/Actions/Service/DeleteService.php index 194cf4db9..93c79383e 100644 --- a/app/Actions/Service/DeleteService.php +++ b/app/Actions/Service/DeleteService.php @@ -3,17 +3,18 @@ namespace App\Actions\Service; use App\Models\Service; +use App\Actions\Server\CleanupDocker; use Lorisleiva\Actions\Concerns\AsAction; class DeleteService { use AsAction; - public function handle(Service $service) + public function handle(Service $service, bool $deleteConfigurations, bool $deleteVolumes, bool $dockerCleanup, bool $deleteConnectedNetworks) { try { $server = data_get($service, 'server'); - if ($server->isFunctional()) { + if ($deleteVolumes && $server->isFunctional()) { $storagesToDelete = collect([]); $service->environment_variables()->delete(); @@ -33,13 +34,29 @@ class DeleteService foreach ($storagesToDelete as $storage) { $commands[] = "docker volume rm -f $storage->name"; } - $commands[] = "docker rm -f $service->uuid"; - instant_remote_process($commands, $server, false); + // Execute volume deletion first, this must be done first otherwise volumes will not be deleted. + if (!empty($commands)) { + foreach ($commands as $command) { + $result = instant_remote_process([$command], $server, false); + if ($result !== 0) { + ray("Failed to execute: $command"); + } + } + } } + + if ($deleteConnectedNetworks) { + $service->delete_connected_networks($service->uuid); + } + + instant_remote_process(["docker rm -f $service->uuid"], $server, throwError: false); } catch (\Exception $e) { throw new \Exception($e->getMessage()); } finally { + if ($deleteConfigurations) { + $service->delete_configurations(); + } foreach ($service->applications()->get() as $application) { $application->forceDelete(); } @@ -50,6 +67,11 @@ class DeleteService $task->delete(); } $service->tags()->detach(); + $service->forceDelete(); + + if ($dockerCleanup) { + CleanupDocker::run($server, true); + } } } } diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index 82b0b3ece..d69898a58 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -3,46 +3,33 @@ namespace App\Actions\Service; use App\Models\Service; +use App\Actions\Server\CleanupDocker; use Lorisleiva\Actions\Concerns\AsAction; class StopService { use AsAction; - public function handle(Service $service) + public function handle(Service $service, bool $isDeleteOperation = false, bool $dockerCleanup = true) { try { $server = $service->destination->server; - if (! $server->isFunctional()) { + if (!$server->isFunctional()) { return 'Server is not functional'; } - ray('Stopping service: '.$service->name); - $applications = $service->applications()->get(); - foreach ($applications as $application) { - if ($applications->count() < 6) { - instant_remote_process(command: ["docker stop --time=10 {$application->name}-{$service->uuid}"], server: $server, throwError: false); - } - instant_remote_process(command: ["docker rm {$application->name}-{$service->uuid}"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm -f {$application->name}-{$service->uuid}"], server: $server, throwError: false); - $application->update(['status' => 'exited']); - } - $dbs = $service->databases()->get(); - foreach ($dbs as $db) { - if ($dbs->count() < 6) { - instant_remote_process(command: ["docker stop --time=10 {$db->name}-{$service->uuid}"], server: $server, throwError: false); + $containersToStop = $service->getContainersToStop(); + $service->stopContainers($containersToStop, $server); + + if (!$isDeleteOperation) { + $service->delete_connected_networks($service->uuid); + if ($dockerCleanup) { + CleanupDocker::run($server, true); } - instant_remote_process(command: ["docker rm {$db->name}-{$service->uuid}"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm -f {$db->name}-{$service->uuid}"], server: $server, throwError: false); - $db->update(['status' => 'exited']); } - instant_remote_process(["docker network disconnect {$service->uuid} coolify-proxy"], $service->server); - instant_remote_process(["docker network rm {$service->uuid}"], $service->server); } catch (\Exception $e) { ray($e->getMessage()); - return $e->getMessage(); } - } } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index df7daa3b4..5ee0ef189 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -29,6 +29,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Collection; use Illuminate\Support\Sleep; use Illuminate\Support\Str; +use Illuminate\Support\Facades\Process; use RuntimeException; use Symfony\Component\Yaml\Yaml; use Throwable; @@ -2200,20 +2201,40 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $this->application_deployment_queue->addLogEntry('Building docker image completed.'); } - /** - * @param int $timeout in seconds - */ - private function graceful_shutdown_container(string $containerName, int $timeout = 30) + private function graceful_shutdown_container(string $containerName, int $timeout = 300) { try { - $this->execute_remote_command( - ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true], - ["docker rm $containerName", 'hidden' => true, 'ignore_errors' => true] - ); + $process = Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + + $startTime = time(); + while ($process->running()) { + if (time() - $startTime >= $timeout) { + $this->execute_remote_command( + ["docker kill $containerName", 'hidden' => true, 'ignore_errors' => true] + ); + break; + } + usleep(100000); + } + + $isRunning = $this->execute_remote_command( + ["docker inspect -f '{{.State.Running}}' $containerName", 'hidden' => true, 'ignore_errors' => true] + ) === 'true'; + + if ($isRunning) { + $this->execute_remote_command( + ["docker kill $containerName", 'hidden' => true, 'ignore_errors' => true] + ); + } } catch (\Exception $error) { - // report error if needed + $this->application_deployment_queue->addLogEntry("Error stopping container $containerName: " . $error->getMessage(), 'stderr'); } + $this->remove_container($containerName); + } + + private function remove_container(string $containerName) + { $this->execute_remote_command( ["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true] ); @@ -2229,7 +2250,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); if ($this->pull_request_id === 0) { $containers = $containers->filter(function ($container) { - return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== $this->container_name.'-pr-'.$this->pull_request_id; + return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== $this->container_name . '-pr-' . $this->pull_request_id; }); } $containers->each(function ($container) { diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index dbf44dd5d..37300593e 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -4,6 +4,7 @@ namespace App\Jobs; use App\Actions\Application\StopApplication; use App\Actions\Database\StopDatabase; +use App\Actions\Server\CleanupDocker; use App\Actions\Service\DeleteService; use App\Actions\Service\StopService; use App\Models\Application; @@ -30,8 +31,11 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue public function __construct( public Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource, - public bool $deleteConfigurations = false, - public bool $deleteVolumes = false) {} + public bool $deleteConfigurations, + public bool $deleteVolumes, + public bool $dockerCleanup, + public bool $deleteConnectedNetworks + ) {} public function handle() { @@ -51,11 +55,11 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue case 'standalone-dragonfly': case 'standalone-clickhouse': $persistentStorages = $this->resource?->persistentStorages()?->get(); - StopDatabase::run($this->resource); + StopDatabase::run($this->resource, true); break; case 'service': - StopService::run($this->resource); - DeleteService::run($this->resource); + StopService::run($this->resource, true); + DeleteService::run($this->resource, $this->deleteConfigurations, $this->deleteVolumes, $this->dockerCleanup, $this->deleteConnectedNetworks); break; } @@ -65,12 +69,31 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue if ($this->deleteConfigurations) { $this->resource?->delete_configurations(); } + + $isDatabase = $this->resource instanceof StandalonePostgresql + || $this->resource instanceof StandaloneRedis + || $this->resource instanceof StandaloneMongodb + || $this->resource instanceof StandaloneMysql + || $this->resource instanceof StandaloneMariadb + || $this->resource instanceof StandaloneKeydb + || $this->resource instanceof StandaloneDragonfly + || $this->resource instanceof StandaloneClickhouse; + $server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server'); + if (($this->dockerCleanup || $isDatabase) && $server) { + CleanupDocker::run($server, true); + } + + if ($this->deleteConnectedNetworks && ! $isDatabase) { + $this->resource?->delete_connected_networks($this->resource->uuid); + } } catch (\Throwable $e) { - ray($e->getMessage()); send_internal_notification('ContainerStoppingJob failed with: '.$e->getMessage()); throw $e; } finally { $this->resource->forceDelete(); + if ($this->dockerCleanup) { + CleanupDocker::run($server, true); + } Artisan::queue('cleanup:stucked-resources'); } } diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 68555d26c..1f0b68dd3 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -30,7 +30,6 @@ class Dashboard extends Component public function cleanup_queue() { - $this->dispatch('success', 'Cleanup started.'); Artisan::queue('cleanup:application-deployment-queue', [ '--team-id' => currentTeam()->id, ]); diff --git a/app/Livewire/Destination/Form.php b/app/Livewire/Destination/Form.php index 7125f2120..5b3115dea 100644 --- a/app/Livewire/Destination/Form.php +++ b/app/Livewire/Destination/Form.php @@ -1,7 +1,6 @@ destination->delete(); - return redirect()->route('dashboard'); + return redirect()->route('destination.all'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/NavbarDeleteTeam.php b/app/Livewire/NavbarDeleteTeam.php index ec196c154..6b3ea3fd4 100644 --- a/app/Livewire/NavbarDeleteTeam.php +++ b/app/Livewire/NavbarDeleteTeam.php @@ -4,11 +4,25 @@ namespace App\Livewire; use Illuminate\Support\Facades\DB; use Livewire\Component; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; class NavbarDeleteTeam extends Component { - public function delete() + public $team; + + public function mount() { + $this->team = currentTeam()->name; + } + + public function delete($password) + { + if (!Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + return; + } + $currentTeam = currentTeam(); $currentTeam->delete(); diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index c02949e17..1082b48cd 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -21,6 +21,8 @@ class Heading extends Component protected string $deploymentUuid; + public bool $docker_cleanup = true; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -102,7 +104,7 @@ class Heading extends Component public function stop() { - StopApplication::run($this->application); + StopApplication::run($this->application, false, $this->docker_cleanup); $this->application->status = 'exited'; $this->application->save(); if ($this->application->additional_servers->count() > 0) { @@ -135,4 +137,13 @@ class Heading extends Component 'environment_name' => $this->parameters['environment_name'], ]); } + + public function render() + { + return view('livewire.project.application.heading', [ + 'checkboxes' => [ + ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], + ], + ]); + } } diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 317a2ae51..397e159ad 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -9,6 +9,8 @@ use Illuminate\Support\Collection; use Livewire\Component; use Spatie\Url\Url; use Visus\Cuid2\Cuid2; +use Illuminate\Process\InvokedProcess; +use Illuminate\Support\Facades\Process; class Previews extends Component { @@ -184,17 +186,20 @@ class Previews extends Component public function stop(int $pull_request_id) { try { + $server = $this->application->destination->server; + $timeout = 300; + if ($this->application->destination->server->isSwarm()) { - instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $this->application->destination->server); + instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $server); } else { - $containers = getCurrentApplicationContainerStatus($this->application->destination->server, $this->application->id, $pull_request_id); - foreach ($containers as $container) { - $name = str_replace('/', '', $container['Names']); - instant_remote_process(["docker rm -f $name"], $this->application->destination->server, throwError: false); - } + $containers = getCurrentApplicationContainerStatus($server, $this->application->id, $pull_request_id)->toArray(); + $this->stopContainers($containers, $server, $timeout); } - GetContainersStatus::dispatchSync($this->application->destination->server)->onQueue('high'); - $this->dispatch('reloadWindow'); + + GetContainersStatus::run($server); + $this->application->refresh(); + $this->dispatch('containerStatusUpdated'); + $this->dispatch('success', 'Preview Deployment stopped.'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -203,16 +208,21 @@ class Previews extends Component public function delete(int $pull_request_id) { try { + $server = $this->application->destination->server; + $timeout = 300; + if ($this->application->destination->server->isSwarm()) { - instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $this->application->destination->server); + instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $server); } else { - $containers = getCurrentApplicationContainerStatus($this->application->destination->server, $this->application->id, $pull_request_id); - foreach ($containers as $container) { - $name = str_replace('/', '', $container['Names']); - instant_remote_process(["docker rm -f $name"], $this->application->destination->server, throwError: false); - } + $containers = getCurrentApplicationContainerStatus($server, $this->application->id, $pull_request_id)->toArray(); + $this->stopContainers($containers, $server, $timeout); } - ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first()->delete(); + + ApplicationPreview::where('application_id', $this->application->id) + ->where('pull_request_id', $pull_request_id) + ->first() + ->delete(); + $this->application->refresh(); $this->dispatch('update_links'); $this->dispatch('success', 'Preview deleted.'); @@ -220,4 +230,49 @@ class Previews extends Component return handleError($e, $this); } } + + private function stopContainers(array $containers, $server, int $timeout) + { + $processes = []; + foreach ($containers as $container) { + $containerName = str_replace('/', '', $container['Names']); + $processes[$containerName] = $this->stopContainer($containerName, $timeout); + } + + $startTime = time(); + while (count($processes) > 0) { + $finishedProcesses = array_filter($processes, function ($process) { + return !$process->running(); + }); + foreach (array_keys($finishedProcesses) as $containerName) { + unset($processes[$containerName]); + $this->removeContainer($containerName, $server); + } + + if (time() - $startTime >= $timeout) { + $this->forceStopRemainingContainers(array_keys($processes), $server); + break; + } + + usleep(100000); + } + } + + private function stopContainer(string $containerName, int $timeout): InvokedProcess + { + return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + } + + private function removeContainer(string $containerName, $server) + { + instant_remote_process(["docker rm -f $containerName"], $server, throwError: false); + } + + private function forceStopRemainingContainers(array $containerNames, $server) + { + foreach ($containerNames as $containerName) { + instant_remote_process(["docker kill $containerName"], $server, throwError: false); + $this->removeContainer($containerName, $server); + } + } } diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 59f2f9a39..9efb2d9fc 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -5,6 +5,8 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; use Livewire\Component; use Spatie\Url\Url; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Auth; class BackupEdit extends Component { @@ -12,6 +14,10 @@ class BackupEdit extends Component public $s3s; + public bool $delete_associated_backups_locally = false; + public bool $delete_associated_backups_s3 = false; + public bool $delete_associated_backups_sftp = false; + public ?string $status = null; public array $parameters; @@ -46,16 +52,29 @@ class BackupEdit extends Component } } - public function delete() + public function delete($password) { + if (!Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + return; + } + try { + if ($this->delete_associated_backups_locally) { + $this->deleteAssociatedBackupsLocally(); + } + if ($this->delete_associated_backups_s3) { + $this->deleteAssociatedBackupsS3(); + } + $this->backup->delete(); + if ($this->backup->database->getMorphClass() === 'App\Models\ServiceDatabase') { $previousUrl = url()->previous(); $url = Url::fromString($previousUrl); $url = $url->withoutQueryParameter('selectedBackupId'); $url = $url->withFragment('backups'); - $url = $url->getPath()."#{$url->getFragment()}"; + $url = $url->getPath() . "#{$url->getFragment()}"; return redirect($url); } else { @@ -104,4 +123,66 @@ class BackupEdit extends Component $this->dispatch('error', $e->getMessage()); } } + + public function deleteAssociatedBackupsLocally() + { + $executions = $this->backup->executions; + $backupFolder = null; + + foreach ($executions as $execution) { + if ($this->backup->database->getMorphClass() === 'App\Models\ServiceDatabase') { + $server = $this->backup->database->service->destination->server; + } else { + $server = $this->backup->database->destination->server; + } + + if (!$backupFolder) { + $backupFolder = dirname($execution->filename); + } + + delete_backup_locally($execution->filename, $server); + $execution->delete(); + } + + if ($backupFolder) { + $this->deleteEmptyBackupFolder($backupFolder, $server); + } + } + + public function deleteAssociatedBackupsS3() + { + //Add function to delete backups from S3 + } + + public function deleteAssociatedBackupsSftp() + { + //Add function to delete backups from SFTP + } + + private function deleteEmptyBackupFolder($folderPath, $server) + { + $checkEmpty = instant_remote_process(["[ -z \"$(ls -A '$folderPath')\" ] && echo 'empty' || echo 'not empty'"], $server); + + if (trim($checkEmpty) === 'empty') { + instant_remote_process(["rmdir '$folderPath'"], $server); + + $parentFolder = dirname($folderPath); + $checkParentEmpty = instant_remote_process(["[ -z \"$(ls -A '$parentFolder')\" ] && echo 'empty' || echo 'not empty'"], $server); + + if (trim($checkParentEmpty) === 'empty') { + instant_remote_process(["rmdir '$parentFolder'"], $server); + } + } + } + + public function render() + { + return view('livewire.project.database.backup-edit', [ + 'checkboxes' => [ + ['id' => 'delete_associated_backups_locally', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from local storage.'], + // ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from the selected S3 Storage.'] + // ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from the selected SFTP Storage.'] + ] + ]); + } } diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 5d56ea53d..e16397652 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -4,6 +4,9 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; use Livewire\Component; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Auth; +use Livewire\Attributes\On; class BackupExecutions extends Component { @@ -12,9 +15,12 @@ class BackupExecutions extends Component public $executions = []; public $setDeletableBackup; + public $delete_backup_s3 = true; + public $delete_backup_sftp = true; + public function getListeners() { - $userId = auth()->user()->id; + $userId = Auth::id(); return [ "echo-private:team.{$userId},BackupCreated" => 'refreshBackupExecutions', @@ -31,19 +37,34 @@ class BackupExecutions extends Component } } - public function deleteBackup($exeuctionId) + #[On('deleteBackup')] + public function deleteBackup($executionId, $password) { - $execution = $this->backup->executions()->where('id', $exeuctionId)->first(); - if (is_null($execution)) { - $this->dispatch('error', 'Backup execution not found.'); - + if (!Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); return; } + + $execution = $this->backup->executions()->where('id', $executionId)->first(); + if (is_null($execution)) { + $this->dispatch('error', 'Backup execution not found.'); + return; + } + if ($execution->scheduledDatabaseBackup->database->getMorphClass() === 'App\Models\ServiceDatabase') { delete_backup_locally($execution->filename, $execution->scheduledDatabaseBackup->database->service->destination->server); } else { delete_backup_locally($execution->filename, $execution->scheduledDatabaseBackup->database->destination->server); } + + if ($this->delete_backup_s3) { + // Add logic to delete from S3 + } + + if ($this->delete_backup_sftp) { + // Add logic to delete from SFTP + } + $execution->delete(); $this->dispatch('success', 'Backup deleted.'); $this->refreshBackupExecutions(); @@ -106,4 +127,14 @@ class BackupExecutions extends Component } return $dateObj->format('Y-m-d H:i:s T'); } + + public function render() + { + return view('livewire.project.database.backup-executions', [ + 'checkboxes' => [ + ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently form S3 Storage'], + ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently form SFTP Storage'], + ] + ]); + } } diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 6435f6781..765213f60 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -14,6 +14,8 @@ class Heading extends Component public array $parameters; + public $docker_cleanup = true; + public function getListeners() { $userId = auth()->user()->id; @@ -54,7 +56,7 @@ class Heading extends Component public function stop() { - StopDatabase::run($this->database); + StopDatabase::run($this->database, false, $this->docker_cleanup); $this->database->status = 'exited'; $this->database->save(); $this->check_status(); @@ -71,4 +73,13 @@ class Heading extends Component $activity = StartDatabase::run($this->database); $this->dispatch('activityMonitor', $activity->id); } + + public function render() + { + return view('livewire.project.database.heading', [ + 'checkboxes' => [ + ['id' => 'docker_cleanup', 'label' => 'Cleanup docker build cache and unused images (next deployment could take longer).'], + ], + ]); + } } diff --git a/app/Livewire/Project/DeleteEnvironment.php b/app/Livewire/Project/DeleteEnvironment.php index 22478916f..e01741770 100644 --- a/app/Livewire/Project/DeleteEnvironment.php +++ b/app/Livewire/Project/DeleteEnvironment.php @@ -13,9 +13,12 @@ class DeleteEnvironment extends Component public bool $disabled = false; + public string $environmentName = ''; + public function mount() { $this->parameters = get_route_parameters(); + $this->environmentName = Environment::findOrFail($this->environment_id)->name; } public function delete() diff --git a/app/Livewire/Project/DeleteProject.php b/app/Livewire/Project/DeleteProject.php index 499b86e3e..360fad10a 100644 --- a/app/Livewire/Project/DeleteProject.php +++ b/app/Livewire/Project/DeleteProject.php @@ -13,9 +13,12 @@ class DeleteProject extends Component public bool $disabled = false; + public string $projectName = ''; + public function mount() { $this->parameters = get_route_parameters(); + $this->projectName = Project::findOrFail($this->project_id)->name; } public function delete() diff --git a/app/Livewire/Project/Service/Configuration.php b/app/Livewire/Project/Service/Configuration.php index fa44fdfbf..a2e48fee7 100644 --- a/app/Livewire/Project/Service/Configuration.php +++ b/app/Livewire/Project/Service/Configuration.php @@ -52,7 +52,7 @@ class Configuration extends Component $application = $this->service->applications->find($id); if ($application) { $application->restart(); - $this->dispatch('success', 'Application restarted successfully.'); + $this->dispatch('success', 'Service application restarted successfully.'); } } catch (\Exception $e) { return handleError($e, $this); @@ -65,7 +65,7 @@ class Configuration extends Component $database = $this->service->databases->find($id); if ($database) { $database->restart(); - $this->dispatch('success', 'Database restarted successfully.'); + $this->dispatch('success', 'Service database restarted successfully.'); } } catch (\Exception $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 6cd54883e..9fac897bf 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -15,6 +15,8 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use Livewire\Component; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Auth; class FileStorage extends Component { @@ -83,8 +85,13 @@ class FileStorage extends Component } } - public function delete() + public function delete($password) { + if (!Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + return; + } + try { $message = 'File deleted.'; if ($this->fileStorage->is_directory) { @@ -127,8 +134,15 @@ class FileStorage extends Component $this->submit(); } - public function render() + public function render() { - return view('livewire.project.service.file-storage'); + return view('livewire.project.service.file-storage', [ + 'directoryDeletionCheckboxes' => [ + ['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permantely deleted form the server.'], + ], + 'fileDeletionCheckboxes' => [ + ['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted form the server.'], + ] + ]); } } diff --git a/app/Livewire/Project/Service/Navbar.php b/app/Livewire/Project/Service/Navbar.php index e6bb6d9bf..42c9357fd 100644 --- a/app/Livewire/Project/Service/Navbar.php +++ b/app/Livewire/Project/Service/Navbar.php @@ -20,6 +20,8 @@ class Navbar extends Component public $isDeploymentProgress = false; + public $docker_cleanup = true; + public $title = 'Configuration'; public function mount() @@ -42,7 +44,7 @@ class Navbar extends Component public function serviceStarted() { - $this->dispatch('success', 'Service status changed.'); + // $this->dispatch('success', 'Service status changed.'); if (is_null($this->service->config_hash) || $this->service->isConfigurationChanged()) { $this->service->isConfigurationChanged(true); $this->dispatch('configurationChanged'); @@ -62,11 +64,6 @@ class Navbar extends Component $this->dispatch('success', 'Service status updated.'); } - public function render() - { - return view('livewire.project.service.navbar'); - } - public function checkDeployments() { try { @@ -97,14 +94,9 @@ class Navbar extends Component $this->dispatch('activityMonitor', $activity->id); } - public function stop(bool $forceCleanup = false) + public function stop() { - StopService::run($this->service); - if ($forceCleanup) { - $this->dispatch('success', 'Containers cleaned up.'); - } else { - $this->dispatch('success', 'Service stopped.'); - } + StopService::run($this->service, false, $this->docker_cleanup); ServiceStatusChanged::dispatch(); } @@ -123,4 +115,13 @@ class Navbar extends Component $activity = StartService::run($this->service); $this->dispatch('activityMonitor', $activity->id); } + + public function render() + { + return view('livewire.project.service.navbar', [ + 'checkboxes' => [ + ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], + ], + ]); + } } diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php index e7d00c3dd..56b506043 100644 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ b/app/Livewire/Project/Service/ServiceApplicationView.php @@ -3,6 +3,8 @@ namespace App\Livewire\Project\Service; use App\Models\ServiceApplication; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; class ServiceApplicationView extends Component @@ -11,6 +13,10 @@ class ServiceApplicationView extends Component public $parameters; + public $docker_cleanup = true; + + public $delete_volumes = true; + protected $rules = [ 'application.human_name' => 'nullable', 'application.description' => 'nullable', @@ -23,11 +29,6 @@ class ServiceApplicationView extends Component 'application.is_stripprefix_enabled' => 'nullable|boolean', ]; - public function render() - { - return view('livewire.project.service.service-application-view'); - } - public function updatedApplicationFqdn() { $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); @@ -56,8 +57,14 @@ class ServiceApplicationView extends Component $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); } - public function delete() + public function delete($password) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + try { $this->application->delete(); $this->dispatch('success', 'Application deleted.'); @@ -91,4 +98,17 @@ class ServiceApplicationView extends Component $this->dispatch('generateDockerCompose'); } } + + public function render() + { + return view('livewire.project.service.service-application-view', [ + 'checkboxes' => [ + ['id' => 'delete_volumes', 'label' => __('resource.delete_volumes')], + ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], + // ['id' => 'delete_associated_backups_locally', 'label' => 'All backups associated with this Ressource will be permanently deleted from local storage.'], + // ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected S3 Storage.'], + // ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected SFTP Storage.'] + ], + ]); + } } diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 5f0178be4..543e64539 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -3,6 +3,11 @@ namespace App\Livewire\Project\Shared; use App\Jobs\DeleteResourceJob; +use App\Models\Service; +use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -10,6 +15,8 @@ class Danger extends Component { public $resource; + public $resourceName; + public $projectUuid; public $environmentName; @@ -18,22 +25,93 @@ class Danger extends Component public bool $delete_volumes = true; + public bool $docker_cleanup = true; + + public bool $delete_connected_networks = true; + public ?string $modalId = null; + public string $resourceDomain = ''; + public function mount() { - $this->modalId = new Cuid2; $parameters = get_route_parameters(); + $this->modalId = new Cuid2; $this->projectUuid = data_get($parameters, 'project_uuid'); $this->environmentName = data_get($parameters, 'environment_name'); + + if ($this->resource === null) { + if (isset($parameters['service_uuid'])) { + $this->resource = Service::where('uuid', $parameters['service_uuid'])->first(); + } elseif (isset($parameters['stack_service_uuid'])) { + $this->resource = ServiceApplication::where('uuid', $parameters['stack_service_uuid'])->first() + ?? ServiceDatabase::where('uuid', $parameters['stack_service_uuid'])->first(); + } + } + + if ($this->resource === null) { + $this->resourceName = 'Unknown Resource'; + + return; + } + + if (! method_exists($this->resource, 'type')) { + $this->resourceName = 'Unknown Resource'; + + return; + } + + switch ($this->resource->type()) { + case 'application': + $this->resourceName = $this->resource->name ?? 'Application'; + break; + case 'standalone-postgresql': + case 'standalone-redis': + case 'standalone-mongodb': + case 'standalone-mysql': + case 'standalone-mariadb': + case 'standalone-keydb': + case 'standalone-dragonfly': + case 'standalone-clickhouse': + $this->resourceName = $this->resource->name ?? 'Database'; + break; + case 'service': + $this->resourceName = $this->resource->name ?? 'Service'; + break; + case 'service-application': + $this->resourceName = $this->resource->name ?? 'Service Application'; + break; + case 'service-database': + $this->resourceName = $this->resource->name ?? 'Service Database'; + break; + default: + $this->resourceName = 'Unknown Resource'; + } } - public function delete() + public function delete($password) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + + if (! $this->resource) { + $this->addError('resource', 'Resource not found.'); + + return; + } + try { - // $this->authorize('delete', $this->resource); $this->resource->delete(); - DeleteResourceJob::dispatch($this->resource, $this->delete_configurations, $this->delete_volumes); + DeleteResourceJob::dispatch( + $this->resource, + $this->delete_configurations, + $this->delete_volumes, + $this->docker_cleanup, + $this->delete_connected_networks + ); return redirect()->route('project.resource.index', [ 'project_uuid' => $this->projectUuid, @@ -43,4 +121,19 @@ class Danger extends Component return handleError($e, $this); } } + + public function render() + { + return view('livewire.project.shared.danger', [ + 'checkboxes' => [ + ['id' => 'delete_volumes', 'label' => __('resource.delete_volumes')], + ['id' => 'delete_connected_networks', 'label' => __('resource.delete_connected_networks')], + ['id' => 'delete_configurations', 'label' => __('resource.delete_configurations')], + ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], + // ['id' => 'delete_associated_backups_locally', 'label' => 'All backups associated with this Ressource will be permanently deleted from local storage.'], + // ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected S3 Storage.'], + // ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected SFTP Storage.'] + ], + ]); + } } diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index a2c018beb..1be724568 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -10,6 +10,8 @@ use App\Models\Server; use App\Models\StandaloneDocker; use Livewire\Component; use Visus\Cuid2\Cuid2; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Auth; class Destination extends Component { @@ -115,8 +117,13 @@ class Destination extends Component ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id')); } - public function removeServer(int $network_id, int $server_id) + public function removeServer(int $network_id, int $server_id, $password) { + if (!Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + return; + } + if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) { $this->dispatch('error', 'You cannot remove this destination server.', 'You are trying to remove the main server.'); diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 8be4ff643..37f50dd32 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -20,6 +20,8 @@ class Show extends Component public string $type; + public string $scheduledTaskName; + protected $rules = [ 'task.enabled' => 'required|boolean', 'task.name' => 'required|string', @@ -49,6 +51,7 @@ class Show extends Component $this->modalId = new Cuid2; $this->task = ModelsScheduledTask::where('uuid', request()->route('task_uuid'))->first(); + $this->scheduledTaskName = $this->task->name; } public function instantSave() @@ -75,9 +78,9 @@ class Show extends Component $this->task->delete(); if ($this->type == 'application') { - return redirect()->route('project.application.configuration', $this->parameters); + return redirect()->route('project.application.configuration', $this->parameters, $this->scheduledTaskName); } else { - return redirect()->route('project.service.configuration', $this->parameters); + return redirect()->route('project.service.configuration', $this->parameters, $this->scheduledTaskName); } } catch (\Exception $e) { return handleError($e); diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 08f51ce08..a0cb54aa0 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -3,6 +3,8 @@ namespace App\Livewire\Project\Shared\Storages; use App\Models\LocalPersistentVolume; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Auth; use Livewire\Component; class Show extends Component @@ -36,8 +38,13 @@ class Show extends Component $this->dispatch('success', 'Storage updated successfully'); } - public function delete() + public function delete($password) { + if (!Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + return; + } + $this->storage->delete(); $this->dispatch('refreshStorages'); } diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index 3beec0c91..08e91a4c7 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -4,6 +4,8 @@ namespace App\Livewire\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Auth; class Delete extends Component { @@ -11,8 +13,12 @@ class Delete extends Component public $server; - public function delete() + public function delete($password) { + if (!Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + return; + } try { $this->authorize('delete', $this->server); if ($this->server->hasDefinedResources()) { diff --git a/app/Livewire/Server/Proxy/Deploy.php b/app/Livewire/Server/Proxy/Deploy.php index 2279951ee..f7318c8f4 100644 --- a/app/Livewire/Server/Proxy/Deploy.php +++ b/app/Livewire/Server/Proxy/Deploy.php @@ -7,6 +7,8 @@ use App\Actions\Proxy\StartProxy; use App\Events\ProxyStatusChanged; use App\Models\Server; use Livewire\Component; +use Illuminate\Support\Facades\Process; +use Illuminate\Process\InvokedProcess; class Deploy extends Component { @@ -94,21 +96,43 @@ class Deploy extends Component public function stop(bool $forceStop = true) { try { - if ($this->server->isSwarm()) { - instant_remote_process([ - 'docker service rm coolify-proxy_traefik', - ], $this->server); - } else { - instant_remote_process([ - 'docker rm -f coolify-proxy', - ], $this->server); + $containerName = $this->server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy'; + $timeout = 30; + + $process = $this->stopContainer($containerName, $timeout); + + $startTime = time(); + while ($process->running()) { + if (time() - $startTime >= $timeout) { + $this->forceStopContainer($containerName); + break; + } + usleep(100000); } - $this->server->proxy->status = 'exited'; - $this->server->proxy->force_stop = $forceStop; - $this->server->save(); - $this->dispatch('proxyStatusUpdated'); + + $this->removeContainer($containerName); } catch (\Throwable $e) { return handleError($e, $this); + } finally { + $this->server->proxy->force_stop = $forceStop; + $this->server->proxy->status = 'exited'; + $this->server->save(); + $this->dispatch('proxyStatusUpdated'); } } + + private function stopContainer(string $containerName, int $timeout): InvokedProcess + { + return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + } + + private function forceStopContainer(string $containerName) + { + instant_remote_process(["docker kill $containerName"], $this->server, throwError: false); + } + + private function removeContainer(string $containerName) + { + instant_remote_process(["docker rm -f $containerName"], $this->server, throwError: false); + } } diff --git a/app/Livewire/Team/AdminView.php b/app/Livewire/Team/AdminView.php index 97d4fcdbf..ee5e673a5 100644 --- a/app/Livewire/Team/AdminView.php +++ b/app/Livewire/Team/AdminView.php @@ -5,6 +5,8 @@ namespace App\Livewire\Team; use App\Models\Team; use App\Models\User; use Livewire\Component; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; class AdminView extends Component { @@ -73,8 +75,12 @@ class AdminView extends Component $team->delete(); } - public function delete($id) + public function delete($id, $password) { + if (!Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + return; + } if (! auth()->user()->isInstanceAdmin()) { return $this->dispatch('error', 'You are not authorized to delete users'); } diff --git a/app/Models/Application.php b/app/Models/Application.php index d0cc34a06..8c7520eaf 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -8,6 +8,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use Illuminate\Support\Facades\Process; +use Illuminate\Process\InvokedProcess; use OpenApi\Attributes as OA; use RuntimeException; use Spatie\Activitylog\Models\Activity; @@ -149,13 +151,65 @@ class Application extends BaseModel return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name'); } + public function getContainersToStop(bool $previewDeployments = false): array + { + $containers = $previewDeployments + ? getCurrentApplicationContainerStatus($this->destination->server, $this->id, includePullrequests: true) + : getCurrentApplicationContainerStatus($this->destination->server, $this->id, 0); + + return $containers->pluck('Names')->toArray(); + } + + public function stopContainers(array $containerNames, $server, int $timeout = 600) + { + $processes = []; + foreach ($containerNames as $containerName) { + $processes[$containerName] = $this->stopContainer($containerName, $server, $timeout); + } + + $startTime = time(); + while (count($processes) > 0) { + $finishedProcesses = array_filter($processes, function ($process) { + return !$process->running(); + }); + foreach ($finishedProcesses as $containerName => $process) { + unset($processes[$containerName]); + $this->removeContainer($containerName, $server); + } + + if (time() - $startTime >= $timeout) { + $this->forceStopRemainingContainers(array_keys($processes), $server); + break; + } + + usleep(100000); + } + } + + public function stopContainer(string $containerName, $server, int $timeout): InvokedProcess + { + return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + } + + public function removeContainer(string $containerName, $server) + { + instant_remote_process(command: ["docker rm -f $containerName"], server: $server, throwError: false); + } + + public function forceStopRemainingContainers(array $containerNames, $server) + { + foreach ($containerNames as $containerName) { + instant_remote_process(command: ["docker kill $containerName"], server: $server, throwError: false); + $this->removeContainer($containerName, $server); + } + } + public function delete_configurations() { $server = data_get($this, 'destination.server'); $workdir = $this->workdir(); if (str($workdir)->endsWith($this->uuid)) { - ray('Deleting workdir'); - instant_remote_process(['rm -rf '.$this->workdir()], $server, false); + instant_remote_process(['rm -rf ' . $this->workdir()], $server, false); } } @@ -176,6 +230,14 @@ class Application extends BaseModel } } + public function delete_connected_networks($uuid) + { + $server = data_get($this, 'destination.server'); + instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false); + instant_remote_process(["docker network rm {$uuid}"], $server, false); + } + + public function additional_servers() { return $this->belongsToMany(Server::class, 'additional_destinations') @@ -283,7 +345,7 @@ class Application extends BaseModel public function publishDirectory(): Attribute { return Attribute::make( - set: fn ($value) => $value ? '/'.ltrim($value, '/') : null, + set: fn ($value) => $value ? '/' . ltrim($value, '/') : null, ); } @@ -291,7 +353,7 @@ class Application extends BaseModel { return Attribute::make( get: function () { - if (! is_null($this->source?->html_url) && ! is_null($this->git_repository) && ! is_null($this->git_branch)) { + if (!is_null($this->source?->html_url) && !is_null($this->git_repository) && !is_null($this->git_branch)) { if (str($this->git_repository)->contains('bitbucket')) { return "{$this->source->html_url}/{$this->git_repository}/src/{$this->git_branch}"; } @@ -318,7 +380,7 @@ class Application extends BaseModel { return Attribute::make( get: function () { - if (! is_null($this->source?->html_url) && ! is_null($this->git_repository) && ! is_null($this->git_branch)) { + if (!is_null($this->source?->html_url) && !is_null($this->git_repository) && !is_null($this->git_branch)) { return "{$this->source->html_url}/{$this->git_repository}/settings/hooks"; } // Convert the SSH URL to HTTPS URL @@ -337,7 +399,7 @@ class Application extends BaseModel { return Attribute::make( get: function () { - if (! is_null($this->source?->html_url) && ! is_null($this->git_repository) && ! is_null($this->git_branch)) { + if (!is_null($this->source?->html_url) && !is_null($this->git_repository) && !is_null($this->git_branch)) { return "{$this->source->html_url}/{$this->git_repository}/commits/{$this->git_branch}"; } // Convert the SSH URL to HTTPS URL @@ -354,7 +416,7 @@ class Application extends BaseModel public function gitCommitLink($link): string { - if (! is_null(data_get($this, 'source.html_url')) && ! is_null(data_get($this, 'git_repository')) && ! is_null(data_get($this, 'git_branch'))) { + if (!is_null(data_get($this, 'source.html_url')) && !is_null(data_get($this, 'git_repository')) && !is_null(data_get($this, 'git_branch'))) { if (str($this->source->html_url)->contains('bitbucket')) { return "{$this->source->html_url}/{$this->git_repository}/commits/{$link}"; } @@ -365,7 +427,7 @@ class Application extends BaseModel $git_repository = str_replace('.git', '', $this->git_repository); $url = Url::fromString($git_repository); $url = $url->withUserInfo(''); - $url = $url->withPath($url->getPath().'/commits/'.$link); + $url = $url->withPath($url->getPath() . '/commits/' . $link); return $url->__toString(); } @@ -418,7 +480,7 @@ class Application extends BaseModel public function baseDirectory(): Attribute { return Attribute::make( - set: fn ($value) => '/'.ltrim($value, '/'), + set: fn ($value) => '/' . ltrim($value, '/'), ); } @@ -761,7 +823,7 @@ class Application extends BaseModel public function workdir() { - return application_configuration_dir()."/{$this->uuid}"; + return application_configuration_dir() . "/{$this->uuid}"; } public function isLogDrainEnabled() @@ -771,7 +833,7 @@ class Application extends BaseModel public function isConfigurationChanged(bool $save = false) { - $newConfigHash = $this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect; + $newConfigHash = $this->fqdn . $this->git_repository . $this->git_branch . $this->git_commit_sha . $this->build_pack . $this->static_image . $this->install_command . $this->build_command . $this->start_command . $this->ports_exposes . $this->ports_mappings . $this->base_directory . $this->publish_directory . $this->dockerfile . $this->dockerfile_location . $this->custom_labels . $this->custom_docker_run_options . $this->dockerfile_target_build . $this->redirect; if ($this->pull_request_id === 0 || $this->pull_request_id === null) { $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); } else { @@ -825,7 +887,7 @@ class Application extends BaseModel public function dirOnServer() { - return application_configuration_dir()."/{$this->uuid}"; + return application_configuration_dir() . "/{$this->uuid}"; } public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false) @@ -871,7 +933,7 @@ class Application extends BaseModel if ($this->source->is_public) { $fullRepoUrl = "{$this->source->html_url}/{$customRepository}"; $git_clone_command = "{$git_clone_command} {$this->source->html_url}/{$customRepository} {$baseDir}"; - if (! $only_checkout) { + if (!$only_checkout) { $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true); } if ($exec_in_docker) { @@ -888,7 +950,7 @@ class Application extends BaseModel $git_clone_command = "{$git_clone_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository} {$baseDir}"; $fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; } - if (! $only_checkout) { + if (!$only_checkout) { $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false); } if ($exec_in_docker) { @@ -949,7 +1011,7 @@ class Application extends BaseModel } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && " . $this->buildGitCheckoutCommand($pr_branch_name); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -957,14 +1019,14 @@ class Application extends BaseModel } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && " . $this->buildGitCheckoutCommand($pr_branch_name); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit); + $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" " . $this->buildGitCheckoutCommand($commit); } } @@ -993,7 +1055,7 @@ class Application extends BaseModel } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && " . $this->buildGitCheckoutCommand($pr_branch_name); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -1001,14 +1063,14 @@ class Application extends BaseModel } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && " . $this->buildGitCheckoutCommand($pr_branch_name); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit); + $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" " . $this->buildGitCheckoutCommand($commit); } } @@ -1060,20 +1122,20 @@ class Application extends BaseModel } if ($source->startsWith('.')) { $source = $source->after('.'); - $source = $workdir.$source; + $source = $workdir . $source; } $commands->push("mkdir -p $source > /dev/null 2>&1 || true"); } } } $labels = collect(data_get($service, 'labels', [])); - if (! $labels->contains('coolify.managed')) { + if (!$labels->contains('coolify.managed')) { $labels->push('coolify.managed=true'); } - if (! $labels->contains('coolify.applicationId')) { - $labels->push('coolify.applicationId='.$this->id); + if (!$labels->contains('coolify.applicationId')) { + $labels->push('coolify.applicationId=' . $this->id); } - if (! $labels->contains('coolify.type')) { + if (!$labels->contains('coolify.type')) { $labels->push('coolify.type=application'); } data_set($service, 'labels', $labels->toArray()); @@ -1149,7 +1211,7 @@ class Application extends BaseModel $jsonNames = $json->keys()->toArray(); $diff = array_diff($jsonNames, $names); $json = $json->filter(function ($value, $key) use ($diff) { - return ! in_array($key, $diff); + return !in_array($key, $diff); }); if ($json) { $this->docker_compose_domains = json_encode($json); @@ -1166,13 +1228,12 @@ class Application extends BaseModel } else { throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile

Check if you used the right extension (.yaml or .yml) in the compose file name."); } - } public function parseContainerLabels(?ApplicationPreview $preview = null) { $customLabels = data_get($this, 'custom_labels'); - if (! $customLabels) { + if (!$customLabels) { return; } if (base64_encode(base64_decode($customLabels, true)) !== $customLabels) { @@ -1255,10 +1316,10 @@ class Application extends BaseModel continue; } if (isset($healthcheckCommand) && str_contains($trimmedLine, '\\')) { - $healthcheckCommand .= ' '.trim($trimmedLine, '\\ '); + $healthcheckCommand .= ' ' . trim($trimmedLine, '\\ '); } - if (isset($healthcheckCommand) && ! str_contains($trimmedLine, '\\') && ! empty($healthcheckCommand)) { - $healthcheckCommand .= ' '.$trimmedLine; + if (isset($healthcheckCommand) && !str_contains($trimmedLine, '\\') && !empty($healthcheckCommand)) { + $healthcheckCommand .= ' ' . $trimmedLine; break; } } diff --git a/app/Models/Service.php b/app/Models/Service.php index d8def6663..80464db7d 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -7,6 +7,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Process; +use Illuminate\Process\InvokedProcess; use Illuminate\Support\Facades\Storage; use OpenApi\Attributes as OA; use Spatie\Url\Url; @@ -68,7 +70,7 @@ class Service extends BaseModel $databaseStorages = $this->databases()->get()->pluck('persistentStorages')->flatten()->sortBy('id'); $storages = $applicationStorages->merge($databaseStorages)->implode('updated_at'); - $newConfigHash = $images.$domains.$images.$storages; + $newConfigHash = $images . $domains . $images . $storages; $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); @@ -131,15 +133,80 @@ class Service extends BaseModel return $this->morphToMany(Tag::class, 'taggable'); } + public function getContainersToStop(): array + { + $containersToStop = []; + $applications = $this->applications()->get(); + foreach ($applications as $application) { + $containersToStop[] = "{$application->name}-{$this->uuid}"; + } + $dbs = $this->databases()->get(); + foreach ($dbs as $db) { + $containersToStop[] = "{$db->name}-{$this->uuid}"; + } + return $containersToStop; + } + + public function stopContainers(array $containerNames, $server, int $timeout = 300) + { + $processes = []; + foreach ($containerNames as $containerName) { + $processes[$containerName] = $this->stopContainer($containerName, $timeout); + } + + $startTime = time(); + while (count($processes) > 0) { + $finishedProcesses = array_filter($processes, function ($process) { + return !$process->running(); + }); + foreach (array_keys($finishedProcesses) as $containerName) { + unset($processes[$containerName]); + $this->removeContainer($containerName, $server); + } + + if (time() - $startTime >= $timeout) { + $this->forceStopRemainingContainers(array_keys($processes), $server); + break; + } + + usleep(100000); + } + } + + public function stopContainer(string $containerName, int $timeout): InvokedProcess + { + return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + } + + public function removeContainer(string $containerName, $server) + { + instant_remote_process(command: ["docker rm -f $containerName"], server: $server, throwError: false); + } + + public function forceStopRemainingContainers(array $containerNames, $server) + { + foreach ($containerNames as $containerName) { + instant_remote_process(command: ["docker kill $containerName"], server: $server, throwError: false); + $this->removeContainer($containerName, $server); + } + } + public function delete_configurations() { - $server = data_get($this, 'server'); + $server = data_get($this, 'destination.server'); $workdir = $this->workdir(); if (str($workdir)->endsWith($this->uuid)) { - instant_remote_process(['rm -rf '.$this->workdir()], $server, false); + instant_remote_process(['rm -rf ' . $this->workdir()], $server, false); } } + public function delete_connected_networks($uuid) + { + $server = data_get($this, 'destination.server'); + instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false); + instant_remote_process(["docker network rm {$uuid}"], $server, false); + } + public function status() { $applications = $this->applications; @@ -994,7 +1061,7 @@ class Service extends BaseModel public function workdir() { - return service_configuration_dir()."/{$this->uuid}"; + return service_configuration_dir() . "/{$this->uuid}"; } public function saveComposeConfigs() diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index d312fab96..f0c78cc08 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -23,7 +23,7 @@ class ServiceApplication extends BaseModel public function restart() { - $container_id = $this->name.'-'.$this->service->uuid; + $container_id = $this->name . '-' . $this->service->uuid; instant_remote_process(["docker restart {$container_id}"], $this->service->server); } @@ -69,7 +69,7 @@ class ServiceApplication extends BaseModel public function workdir() { - return service_configuration_dir()."/{$this->service->uuid}"; + return service_configuration_dir() . "/{$this->service->uuid}"; } public function serviceType() diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index 6b96738e8..2e25f4402 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -21,7 +21,7 @@ class ServiceDatabase extends BaseModel public function restart() { - $container_id = $this->name.'-'.$this->service->uuid; + $container_id = $this->name . '-' . $this->service->uuid; remote_process(["docker restart {$container_id}"], $this->service->server); } @@ -88,7 +88,7 @@ class ServiceDatabase extends BaseModel public function workdir() { - return service_configuration_dir()."/{$this->service->uuid}"; + return service_configuration_dir() . "/{$this->service->uuid}"; } public function service() diff --git a/config/sentry.php b/config/sentry.php index 471a1e0fc..c65d3d1ff 100644 --- a/config/sentry.php +++ b/config/sentry.php @@ -7,7 +7,7 @@ return [ // The release version of your application // Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')) - 'release' => '4.0.0-beta.341', + 'release' => '4.0.0-beta.342', // When left empty or `null` the Laravel environment will be used 'environment' => config('app.env'), diff --git a/config/version.php b/config/version.php index 32eb01cd0..633c71d60 100644 --- a/config/version.php +++ b/config/version.php @@ -1,3 +1,3 @@ Examples
For Public repositories, use https://....
For Private repositories, use git@....

https://github.com/coollabsio/coolify-examples main branch will be selected
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify branch will be selected.
https://gitea.com/sedlav/expressjs.git main branch will be selected.
https://gitlab.com/andrasbacsai/nodejs-example.git main branch will be selected." + "repository.url": "Examples
For Public repositories, use https://....
For Private repositories, use git@....

https://github.com/coollabsio/coolify-examples main branch will be selected
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify branch will be selected.
https://gitea.com/sedlav/expressjs.git main branch will be selected.
https://gitlab.com/andrasbacsai/nodejs-example.git main branch will be selected.", + "service.stop": "This service will be stopped.", + "resource.docker_cleanup": "Run Docker Cleanup (remove unused images and builder cache).", + "resource.non_persistent": "All non-persistent data will be deleted.", + "resource.delete_volumes": "All volumes associated with this resource will be permanently deleted.", + "resource.delete_connected_networks": "All non-predefined networks associated with this resource will be permanently deleted.", + "resource.delete_configurations": "All configuration files will be permanently deleted from the server." } diff --git a/resources/views/components/forms/checkbox.blade.php b/resources/views/components/forms/checkbox.blade.php index f3e3d5c9e..84c6b7e32 100644 --- a/resources/views/components/forms/checkbox.blade.php +++ b/resources/views/components/forms/checkbox.blade.php @@ -1,4 +1,15 @@ +@props([ + 'id', + 'label' => null, + 'helper' => null, + 'disabled' => false, + 'instantSave' => false, + 'value' => null, + 'hideLabel' => false, +]) +
+ @if(!$hideLabel) + @endif merge(['class' => $defaultClass]) }} @if ($instantSave) wire:loading.attr="disabled" wire:click='{{ $instantSave === 'instantSave' || $instantSave == '1' ? 'instantSave' : $instantSave }}' diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 7910d2eb2..6b46f2fb6 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -1,14 +1,105 @@ @props([ 'title' => 'Are you sure?', 'isErrorButton' => false, - 'buttonTitle' => 'REWRITE THIS BUTTON TITLE PLEASSSSEEEE', + 'buttonTitle' => 'Confirm Action', 'buttonFullWidth' => false, 'customButton' => null, 'disabled' => false, - 'action' => 'delete', + 'submitAction' => 'delete', 'content' => null, + 'checkboxes' => [], + 'actions' => [], + 'confirmWithText' => true, + 'confirmationText' => 'Confirm Deletion', + 'confirmationLabel' => 'Please confirm the execution of the actions by entering the Name below', + 'shortConfirmationLabel' => 'Name', + 'confirmWithPassword' => true, + 'step1ButtonText' => 'Continue', + 'step2ButtonText' => 'Continue', + 'step3ButtonText' => 'Confirm', + 'dispatchEvent' => false, + 'dispatchEventType' => 'success', + 'dispatchEventMessage' => '', ]) -
@if ($customButton) @if ($buttonFullWidth) @@ -60,11 +151,9 @@ @endif @endif