diff --git a/app/Livewire/Concerns/SynchronizesModelData.php b/app/Livewire/Concerns/SynchronizesModelData.php new file mode 100644 index 000000000..f8218c715 --- /dev/null +++ b/app/Livewire/Concerns/SynchronizesModelData.php @@ -0,0 +1,35 @@ + Array mapping property names to model keys (e.g., ['content' => 'fileStorage.content']) + */ + abstract protected function getModelBindings(): array; + + /** + * Synchronize component properties TO the model. + * Copies values from component properties to the model. + */ + protected function syncToModel(): void + { + foreach ($this->getModelBindings() as $property => $modelKey) { + data_set($this, $modelKey, $this->{$property}); + } + } + + /** + * Synchronize component properties FROM the model. + * Copies values from the model to component properties. + */ + protected function syncFromModel(): void + { + foreach ($this->getModelBindings() as $property => $modelKey) { + $this->{$property} = data_get($this, $modelKey); + } + } +} diff --git a/app/Livewire/MonacoEditor.php b/app/Livewire/MonacoEditor.php index 54f0965a2..f660f9c13 100644 --- a/app/Livewire/MonacoEditor.php +++ b/app/Livewire/MonacoEditor.php @@ -25,7 +25,7 @@ public function __construct( public bool $readonly, public bool $allowTab, public bool $spellcheck, - public bool $autofocus = false, + public bool $autofocus, public ?string $helper, public bool $realtimeValidation, public bool $allowToPeak, diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index b42f29fa5..a733d8cb3 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Application; use App\Actions\Application\GenerateConfig; +use App\Livewire\Concerns\SynchronizesModelData; use App\Models\Application; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -14,6 +15,7 @@ class General extends Component { use AuthorizesRequests; + use SynchronizesModelData; public string $applicationId; @@ -23,6 +25,8 @@ class General extends Component public string $name; + public ?string $description = null; + public ?string $fqdn = null; public string $git_repository; @@ -31,14 +35,82 @@ class General extends Component public ?string $git_commit_sha = null; + public ?string $install_command = null; + + public ?string $build_command = null; + + public ?string $start_command = null; + public string $build_pack; + public string $static_image; + + public string $base_directory; + + public ?string $publish_directory = null; + public ?string $ports_exposes = null; + public ?string $ports_mappings = null; + + public ?string $custom_network_aliases = null; + + public ?string $dockerfile = null; + + public ?string $dockerfile_location = null; + + public ?string $dockerfile_target_build = null; + + public ?string $docker_registry_image_name = null; + + public ?string $docker_registry_image_tag = null; + + public ?string $docker_compose_location = null; + + public ?string $docker_compose = null; + + public ?string $docker_compose_raw = null; + + public ?string $docker_compose_custom_start_command = null; + + public ?string $docker_compose_custom_build_command = null; + + public ?string $custom_labels = null; + + public ?string $custom_docker_run_options = null; + + public ?string $pre_deployment_command = null; + + public ?string $pre_deployment_command_container = null; + + public ?string $post_deployment_command = null; + + public ?string $post_deployment_command_container = null; + + public ?string $custom_nginx_configuration = null; + + public bool $is_static = false; + + public bool $is_spa = false; + + public bool $is_build_server_enabled = false; + public bool $is_preserve_repository_enabled = false; public bool $is_container_label_escape_enabled = true; + public bool $is_container_label_readonly_enabled = false; + + public bool $is_http_basic_auth_enabled = false; + + public ?string $http_basic_auth_username = null; + + public ?string $http_basic_auth_password = null; + + public ?string $watch_paths = null; + + public string $redirect; + public $customLabels; public bool $labelsChanged = false; @@ -66,50 +138,50 @@ class General extends Component protected function rules(): array { return [ - 'application.name' => ValidationPatterns::nameRules(), - 'application.description' => ValidationPatterns::descriptionRules(), - 'application.fqdn' => 'nullable', - 'application.git_repository' => 'required', - 'application.git_branch' => 'required', - 'application.git_commit_sha' => 'nullable', - 'application.install_command' => 'nullable', - 'application.build_command' => 'nullable', - 'application.start_command' => 'nullable', - 'application.build_pack' => 'required', - 'application.static_image' => 'required', - 'application.base_directory' => 'required', - 'application.publish_directory' => 'nullable', - 'application.ports_exposes' => 'required', - 'application.ports_mappings' => 'nullable', - 'application.custom_network_aliases' => 'nullable', - 'application.dockerfile' => 'nullable', - 'application.docker_registry_image_name' => 'nullable', - 'application.docker_registry_image_tag' => 'nullable', - 'application.dockerfile_location' => 'nullable', - 'application.docker_compose_location' => 'nullable', - 'application.docker_compose' => 'nullable', - 'application.docker_compose_raw' => 'nullable', - 'application.dockerfile_target_build' => 'nullable', - 'application.docker_compose_custom_start_command' => 'nullable', - 'application.docker_compose_custom_build_command' => 'nullable', - 'application.custom_labels' => 'nullable', - 'application.custom_docker_run_options' => 'nullable', - 'application.pre_deployment_command' => 'nullable', - 'application.pre_deployment_command_container' => 'nullable', - 'application.post_deployment_command' => 'nullable', - 'application.post_deployment_command_container' => 'nullable', - 'application.custom_nginx_configuration' => 'nullable', - 'application.settings.is_static' => 'boolean|required', - 'application.settings.is_spa' => 'boolean|required', - 'application.settings.is_build_server_enabled' => 'boolean|required', - 'application.settings.is_container_label_escape_enabled' => 'boolean|required', - 'application.settings.is_container_label_readonly_enabled' => 'boolean|required', - 'application.settings.is_preserve_repository_enabled' => 'boolean|required', - 'application.is_http_basic_auth_enabled' => 'boolean|required', - 'application.http_basic_auth_username' => 'string|nullable', - 'application.http_basic_auth_password' => 'string|nullable', - 'application.watch_paths' => 'nullable', - 'application.redirect' => 'string|required', + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'fqdn' => 'nullable', + 'git_repository' => 'required', + 'git_branch' => 'required', + 'git_commit_sha' => 'nullable', + 'install_command' => 'nullable', + 'build_command' => 'nullable', + 'start_command' => 'nullable', + 'build_pack' => 'required', + 'static_image' => 'required', + 'base_directory' => 'required', + 'publish_directory' => 'nullable', + 'ports_exposes' => 'required', + 'ports_mappings' => 'nullable', + 'custom_network_aliases' => 'nullable', + 'dockerfile' => 'nullable', + 'docker_registry_image_name' => 'nullable', + 'docker_registry_image_tag' => 'nullable', + 'dockerfile_location' => 'nullable', + 'docker_compose_location' => 'nullable', + 'docker_compose' => 'nullable', + 'docker_compose_raw' => 'nullable', + 'dockerfile_target_build' => 'nullable', + 'docker_compose_custom_start_command' => 'nullable', + 'docker_compose_custom_build_command' => 'nullable', + 'custom_labels' => 'nullable', + 'custom_docker_run_options' => 'nullable', + 'pre_deployment_command' => 'nullable', + 'pre_deployment_command_container' => 'nullable', + 'post_deployment_command' => 'nullable', + 'post_deployment_command_container' => 'nullable', + 'custom_nginx_configuration' => 'nullable', + 'is_static' => 'boolean|required', + 'is_spa' => 'boolean|required', + 'is_build_server_enabled' => 'boolean|required', + 'is_container_label_escape_enabled' => 'boolean|required', + 'is_container_label_readonly_enabled' => 'boolean|required', + 'is_preserve_repository_enabled' => 'boolean|required', + 'is_http_basic_auth_enabled' => 'boolean|required', + 'http_basic_auth_username' => 'string|nullable', + 'http_basic_auth_password' => 'string|nullable', + 'watch_paths' => 'nullable', + 'redirect' => 'string|required', ]; } @@ -118,31 +190,31 @@ protected function messages(): array return array_merge( ValidationPatterns::combinedMessages(), [ - 'application.name.required' => 'The Name field is required.', - 'application.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', - 'application.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', - 'application.git_repository.required' => 'The Git Repository field is required.', - 'application.git_branch.required' => 'The Git Branch field is required.', - 'application.build_pack.required' => 'The Build Pack field is required.', - 'application.static_image.required' => 'The Static Image field is required.', - 'application.base_directory.required' => 'The Base Directory field is required.', - 'application.ports_exposes.required' => 'The Exposed Ports field is required.', - 'application.settings.is_static.required' => 'The Static setting is required.', - 'application.settings.is_static.boolean' => 'The Static setting must be true or false.', - 'application.settings.is_spa.required' => 'The SPA setting is required.', - 'application.settings.is_spa.boolean' => 'The SPA setting must be true or false.', - 'application.settings.is_build_server_enabled.required' => 'The Build Server setting is required.', - 'application.settings.is_build_server_enabled.boolean' => 'The Build Server setting must be true or false.', - 'application.settings.is_container_label_escape_enabled.required' => 'The Container Label Escape setting is required.', - 'application.settings.is_container_label_escape_enabled.boolean' => 'The Container Label Escape setting must be true or false.', - 'application.settings.is_container_label_readonly_enabled.required' => 'The Container Label Readonly setting is required.', - 'application.settings.is_container_label_readonly_enabled.boolean' => 'The Container Label Readonly setting must be true or false.', - 'application.settings.is_preserve_repository_enabled.required' => 'The Preserve Repository setting is required.', - 'application.settings.is_preserve_repository_enabled.boolean' => 'The Preserve Repository setting must be true or false.', - 'application.is_http_basic_auth_enabled.required' => 'The HTTP Basic Auth setting is required.', - 'application.is_http_basic_auth_enabled.boolean' => 'The HTTP Basic Auth setting must be true or false.', - 'application.redirect.required' => 'The Redirect setting is required.', - 'application.redirect.string' => 'The Redirect setting must be a string.', + 'name.required' => 'The Name field is required.', + 'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'git_repository.required' => 'The Git Repository field is required.', + 'git_branch.required' => 'The Git Branch field is required.', + 'build_pack.required' => 'The Build Pack field is required.', + 'static_image.required' => 'The Static Image field is required.', + 'base_directory.required' => 'The Base Directory field is required.', + 'ports_exposes.required' => 'The Exposed Ports field is required.', + 'is_static.required' => 'The Static setting is required.', + 'is_static.boolean' => 'The Static setting must be true or false.', + 'is_spa.required' => 'The SPA setting is required.', + 'is_spa.boolean' => 'The SPA setting must be true or false.', + 'is_build_server_enabled.required' => 'The Build Server setting is required.', + 'is_build_server_enabled.boolean' => 'The Build Server setting must be true or false.', + 'is_container_label_escape_enabled.required' => 'The Container Label Escape setting is required.', + 'is_container_label_escape_enabled.boolean' => 'The Container Label Escape setting must be true or false.', + 'is_container_label_readonly_enabled.required' => 'The Container Label Readonly setting is required.', + 'is_container_label_readonly_enabled.boolean' => 'The Container Label Readonly setting must be true or false.', + 'is_preserve_repository_enabled.required' => 'The Preserve Repository setting is required.', + 'is_preserve_repository_enabled.boolean' => 'The Preserve Repository setting must be true or false.', + 'is_http_basic_auth_enabled.required' => 'The HTTP Basic Auth setting is required.', + 'is_http_basic_auth_enabled.boolean' => 'The HTTP Basic Auth setting must be true or false.', + 'redirect.required' => 'The Redirect setting is required.', + 'redirect.string' => 'The Redirect setting must be a string.', ] ); } @@ -193,11 +265,15 @@ public function mount() $this->parsedServices = $this->application->parse(); if (is_null($this->parsedServices) || empty($this->parsedServices)) { $this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.'); + // Still sync data even if parse fails, so form fields are populated + $this->syncFromModel(); return; } } catch (\Throwable $e) { $this->dispatch('error', $e->getMessage()); + // Still sync data even on error, so form fields are populated + $this->syncFromModel(); } if ($this->application->build_pack === 'dockercompose') { // Only update if user has permission @@ -218,9 +294,6 @@ public function mount() } $this->parsedServiceDomains = $sanitizedDomains; - $this->ports_exposes = $this->application->ports_exposes; - $this->is_preserve_repository_enabled = $this->application->settings->is_preserve_repository_enabled; - $this->is_container_label_escape_enabled = $this->application->settings->is_container_label_escape_enabled; $this->customLabels = $this->application->parseContainerLabels(); if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && $this->application->settings->is_container_label_readonly_enabled === true) { // Only update custom labels if user has permission @@ -249,6 +322,60 @@ public function mount() if (str($this->application->status)->startsWith('running') && is_null($this->application->config_hash)) { $this->dispatch('configurationChanged'); } + + // Sync data from model to properties at the END, after all business logic + // This ensures any modifications to $this->application during mount() are reflected in properties + $this->syncFromModel(); + } + + protected function getModelBindings(): array + { + return [ + 'name' => 'application.name', + 'description' => 'application.description', + 'fqdn' => 'application.fqdn', + 'git_repository' => 'application.git_repository', + 'git_branch' => 'application.git_branch', + 'git_commit_sha' => 'application.git_commit_sha', + 'install_command' => 'application.install_command', + 'build_command' => 'application.build_command', + 'start_command' => 'application.start_command', + 'build_pack' => 'application.build_pack', + 'static_image' => 'application.static_image', + 'base_directory' => 'application.base_directory', + 'publish_directory' => 'application.publish_directory', + 'ports_exposes' => 'application.ports_exposes', + 'ports_mappings' => 'application.ports_mappings', + 'custom_network_aliases' => 'application.custom_network_aliases', + 'dockerfile' => 'application.dockerfile', + 'dockerfile_location' => 'application.dockerfile_location', + 'dockerfile_target_build' => 'application.dockerfile_target_build', + 'docker_registry_image_name' => 'application.docker_registry_image_name', + 'docker_registry_image_tag' => 'application.docker_registry_image_tag', + 'docker_compose_location' => 'application.docker_compose_location', + 'docker_compose' => 'application.docker_compose', + 'docker_compose_raw' => 'application.docker_compose_raw', + 'docker_compose_custom_start_command' => 'application.docker_compose_custom_start_command', + 'docker_compose_custom_build_command' => 'application.docker_compose_custom_build_command', + 'custom_labels' => 'application.custom_labels', + 'custom_docker_run_options' => 'application.custom_docker_run_options', + 'pre_deployment_command' => 'application.pre_deployment_command', + 'pre_deployment_command_container' => 'application.pre_deployment_command_container', + 'post_deployment_command' => 'application.post_deployment_command', + 'post_deployment_command_container' => 'application.post_deployment_command_container', + 'custom_nginx_configuration' => 'application.custom_nginx_configuration', + 'is_static' => 'application.settings.is_static', + 'is_spa' => 'application.settings.is_spa', + 'is_build_server_enabled' => 'application.settings.is_build_server_enabled', + 'is_preserve_repository_enabled' => 'application.settings.is_preserve_repository_enabled', + 'is_container_label_escape_enabled' => 'application.settings.is_container_label_escape_enabled', + 'is_container_label_readonly_enabled' => 'application.settings.is_container_label_readonly_enabled', + 'is_http_basic_auth_enabled' => 'application.is_http_basic_auth_enabled', + 'http_basic_auth_username' => 'application.http_basic_auth_username', + 'http_basic_auth_password' => 'application.http_basic_auth_password', + 'watch_paths' => 'application.watch_paths', + 'redirect' => 'application.redirect', + ]; } public function instantSave() @@ -256,6 +383,12 @@ public function instantSave() try { $this->authorize('update', $this->application); + $oldPortsExposes = $this->application->ports_exposes; + $oldIsContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled; + $oldIsPreserveRepositoryEnabled = $this->application->settings->is_preserve_repository_enabled; + + $this->syncToModel(); + if ($this->application->settings->isDirty('is_spa')) { $this->generateNginxConfiguration($this->application->settings->is_spa ? 'spa' : 'static'); } @@ -265,20 +398,21 @@ public function instantSave() $this->application->settings->save(); $this->dispatch('success', 'Settings saved.'); $this->application->refresh(); + $this->syncFromModel(); // If port_exposes changed, reset default labels - if ($this->ports_exposes !== $this->application->ports_exposes || $this->is_container_label_escape_enabled !== $this->application->settings->is_container_label_escape_enabled) { + if ($oldPortsExposes !== $this->ports_exposes || $oldIsContainerLabelEscapeEnabled !== $this->is_container_label_escape_enabled) { $this->resetDefaultLabels(false); } - if ($this->is_preserve_repository_enabled !== $this->application->settings->is_preserve_repository_enabled) { - if ($this->application->settings->is_preserve_repository_enabled === false) { + if ($oldIsPreserveRepositoryEnabled !== $this->is_preserve_repository_enabled) { + if ($this->is_preserve_repository_enabled === false) { $this->application->fileStorages->each(function ($storage) { - $storage->is_based_on_git = $this->application->settings->is_preserve_repository_enabled; + $storage->is_based_on_git = $this->is_preserve_repository_enabled; $storage->save(); }); } } - if ($this->application->settings->is_container_label_readonly_enabled) { + if ($this->is_container_label_readonly_enabled) { $this->resetDefaultLabels(false); } } catch (\Throwable $e) { @@ -366,21 +500,21 @@ public function generateDomain(string $serviceName) } } - public function updatedApplicationBaseDirectory() + public function updatedBaseDirectory() { - if ($this->application->build_pack === 'dockercompose') { + if ($this->build_pack === 'dockercompose') { $this->loadComposeFile(); } } - public function updatedApplicationSettingsIsStatic($value) + public function updatedIsStatic($value) { if ($value) { $this->generateNginxConfiguration(); } } - public function updatedApplicationBuildPack() + public function updatedBuildPack() { // Check if user has permission to update try { @@ -388,21 +522,28 @@ public function updatedApplicationBuildPack() } catch (\Illuminate\Auth\Access\AuthorizationException $e) { // User doesn't have permission, revert the change and return $this->application->refresh(); + $this->syncFromModel(); return; } - if ($this->application->build_pack !== 'nixpacks') { + // Sync property to model before checking/modifying + $this->syncToModel(); + + if ($this->build_pack !== 'nixpacks') { + $this->is_static = false; $this->application->settings->is_static = false; $this->application->settings->save(); } else { - $this->application->ports_exposes = $this->ports_exposes = 3000; + $this->ports_exposes = 3000; + $this->application->ports_exposes = 3000; $this->resetDefaultLabels(false); } - if ($this->application->build_pack === 'dockercompose') { + if ($this->build_pack === 'dockercompose') { // Only update if user has permission try { $this->authorize('update', $this->application); + $this->fqdn = null; $this->application->fqdn = null; $this->application->settings->save(); } catch (\Illuminate\Auth\Access\AuthorizationException $e) { @@ -421,8 +562,9 @@ public function updatedApplicationBuildPack() $this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_URL_%')->delete(); } } - if ($this->application->build_pack === 'static') { - $this->application->ports_exposes = $this->ports_exposes = 80; + if ($this->build_pack === 'static') { + $this->ports_exposes = 80; + $this->application->ports_exposes = 80; $this->resetDefaultLabels(false); $this->generateNginxConfiguration(); } @@ -438,8 +580,11 @@ public function getWildcardDomain() $server = data_get($this->application, 'destination.server'); if ($server) { $fqdn = generateUrl(server: $server, random: $this->application->uuid); - $this->application->fqdn = $fqdn; + $this->fqdn = $fqdn; + $this->syncToModel(); $this->application->save(); + $this->application->refresh(); + $this->syncFromModel(); $this->resetDefaultLabels(); $this->dispatch('success', 'Wildcard domain generated.'); } @@ -453,8 +598,11 @@ public function generateNginxConfiguration($type = 'static') try { $this->authorize('update', $this->application); - $this->application->custom_nginx_configuration = defaultNginxConfiguration($type); + $this->custom_nginx_configuration = defaultNginxConfiguration($type); + $this->syncToModel(); $this->application->save(); + $this->application->refresh(); + $this->syncFromModel(); $this->dispatch('success', 'Nginx configuration generated.'); } catch (\Throwable $e) { return handleError($e, $this); @@ -464,15 +612,16 @@ public function generateNginxConfiguration($type = 'static') public function resetDefaultLabels($manualReset = false) { try { - if (! $this->application->settings->is_container_label_readonly_enabled && ! $manualReset) { + if (! $this->is_container_label_readonly_enabled && ! $manualReset) { return; } $this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n"); - $this->ports_exposes = $this->application->ports_exposes; - $this->is_container_label_escape_enabled = $this->application->settings->is_container_label_escape_enabled; - $this->application->custom_labels = base64_encode($this->customLabels); + $this->custom_labels = base64_encode($this->customLabels); + $this->syncToModel(); $this->application->save(); - if ($this->application->build_pack === 'dockercompose') { + $this->application->refresh(); + $this->syncFromModel(); + if ($this->build_pack === 'dockercompose') { $this->loadComposeFile(showToast: false); } $this->dispatch('configurationChanged'); @@ -483,8 +632,8 @@ public function resetDefaultLabels($manualReset = false) public function checkFqdns($showToaster = true) { - if (data_get($this->application, 'fqdn')) { - $domains = str($this->application->fqdn)->trim()->explode(','); + if ($this->fqdn) { + $domains = str($this->fqdn)->trim()->explode(','); if ($this->application->additional_servers->count() === 0) { foreach ($domains as $domain) { if (! validateDNSEntry($domain, $this->application->destination->server)) { @@ -507,7 +656,8 @@ public function checkFqdns($showToaster = true) $this->forceSaveDomains = false; } - $this->application->fqdn = $domains->implode(','); + $this->fqdn = $domains->implode(','); + $this->application->fqdn = $this->fqdn; $this->resetDefaultLabels(false); } @@ -547,21 +697,27 @@ public function submit($showToaster = true) $this->validate(); - $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); - $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); - $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { + $oldPortsExposes = $this->application->ports_exposes; + $oldIsContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled; + $oldDockerComposeLocation = $this->initialDockerComposeLocation; + + // Process FQDN with intermediate variable to avoid Collection/string confusion + $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->application->fqdn = $this->application->fqdn->unique()->implode(','); - $warning = sslipDomainWarning($this->application->fqdn); + $this->fqdn = $domains->unique()->implode(','); + $warning = sslipDomainWarning($this->fqdn); if ($warning) { $this->dispatch('warning', __('warning.sslipdomain')); } - // $this->resetDefaultLabels(); + + $this->syncToModel(); if ($this->application->isDirty('redirect')) { $this->setRedirect(); @@ -581,38 +737,42 @@ public function submit($showToaster = true) $this->application->save(); } - if ($this->application->build_pack === 'dockercompose' && $this->initialDockerComposeLocation !== $this->application->docker_compose_location) { + if ($this->build_pack === 'dockercompose' && $oldDockerComposeLocation !== $this->docker_compose_location) { $compose_return = $this->loadComposeFile(showToast: false); if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) { return; } } - if ($this->ports_exposes !== $this->application->ports_exposes || $this->is_container_label_escape_enabled !== $this->application->settings->is_container_label_escape_enabled) { + if ($oldPortsExposes !== $this->ports_exposes || $oldIsContainerLabelEscapeEnabled !== $this->is_container_label_escape_enabled) { $this->resetDefaultLabels(); } - if (data_get($this->application, 'build_pack') === 'dockerimage') { + if ($this->build_pack === 'dockerimage') { $this->validate([ - 'application.docker_registry_image_name' => 'required', + 'docker_registry_image_name' => 'required', ]); } - if (data_get($this->application, 'custom_docker_run_options')) { - $this->application->custom_docker_run_options = str($this->application->custom_docker_run_options)->trim(); + if ($this->custom_docker_run_options) { + $this->custom_docker_run_options = str($this->custom_docker_run_options)->trim()->toString(); + $this->application->custom_docker_run_options = $this->custom_docker_run_options; } - if (data_get($this->application, 'dockerfile')) { - $port = get_port_from_dockerfile($this->application->dockerfile); - if ($port && ! $this->application->ports_exposes) { + if ($this->dockerfile) { + $port = get_port_from_dockerfile($this->dockerfile); + if ($port && ! $this->ports_exposes) { + $this->ports_exposes = $port; $this->application->ports_exposes = $port; } } - if ($this->application->base_directory && $this->application->base_directory !== '/') { - $this->application->base_directory = rtrim($this->application->base_directory, '/'); + if ($this->base_directory && $this->base_directory !== '/') { + $this->base_directory = rtrim($this->base_directory, '/'); + $this->application->base_directory = $this->base_directory; } - if ($this->application->publish_directory && $this->application->publish_directory !== '/') { - $this->application->publish_directory = rtrim($this->application->publish_directory, '/'); + if ($this->publish_directory && $this->publish_directory !== '/') { + $this->publish_directory = rtrim($this->publish_directory, '/'); + $this->application->publish_directory = $this->publish_directory; } - if ($this->application->build_pack === 'dockercompose') { + if ($this->build_pack === 'dockercompose') { $this->application->docker_compose_domains = json_encode($this->parsedServiceDomains); if ($this->application->isDirty('docker_compose_domains')) { foreach ($this->parsedServiceDomains as $service) { @@ -643,12 +803,12 @@ public function submit($showToaster = true) } $this->application->custom_labels = base64_encode($this->customLabels); $this->application->save(); + $this->application->refresh(); + $this->syncFromModel(); $showToaster && ! $warning && $this->dispatch('success', 'Application settings updated!'); } catch (\Throwable $e) { - $originalFqdn = $this->application->getOriginal('fqdn'); - if ($originalFqdn !== $this->application->fqdn) { - $this->application->fqdn = $originalFqdn; - } + $this->application->refresh(); + $this->syncFromModel(); return handleError($e, $this); } finally { diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 1cb2ef2c5..e28c8142d 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -33,14 +33,34 @@ class Previews extends Component public $pendingPreviewId = null; + public array $previewFqdns = []; + protected $rules = [ - 'application.previews.*.fqdn' => 'string|nullable', + 'previewFqdns.*' => 'string|nullable', ]; public function mount() { $this->pull_requests = collect(); $this->parameters = get_route_parameters(); + $this->syncData(false); + } + + private function syncData(bool $toModel = false): void + { + if ($toModel) { + foreach ($this->previewFqdns as $key => $fqdn) { + $preview = $this->application->previews->get($key); + if ($preview) { + $preview->fqdn = $fqdn; + } + } + } else { + $this->previewFqdns = []; + foreach ($this->application->previews as $key => $preview) { + $this->previewFqdns[$key] = $preview->fqdn; + } + } } public function load_prs() @@ -73,35 +93,52 @@ public function save_preview($preview_id) $this->authorize('update', $this->application); $success = true; $preview = $this->application->previews->find($preview_id); - if (data_get_str($preview, 'fqdn')->isNotEmpty()) { - $preview->fqdn = str($preview->fqdn)->replaceEnd(',', '')->trim(); - $preview->fqdn = str($preview->fqdn)->replaceStart(',', '')->trim(); - $preview->fqdn = str($preview->fqdn)->trim()->lower(); - if (! validateDNSEntry($preview->fqdn, $this->application->destination->server)) { - $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.

$preview->fqdn->{$this->application->destination->server->ip}

Check this documentation for further help."); - $success = false; - } - // Check for domain conflicts if not forcing save - if (! $this->forceSaveDomains) { - $result = checkDomainUsage(resource: $this->application, domain: $preview->fqdn); - if ($result['hasConflicts']) { - $this->domainConflicts = $result['conflicts']; - $this->showDomainConflictModal = true; - $this->pendingPreviewId = $preview_id; - - return; - } - } else { - // Reset the force flag after using it - $this->forceSaveDomains = false; - } - } if (! $preview) { throw new \Exception('Preview not found'); } - $success && $preview->save(); - $success && $this->dispatch('success', 'Preview saved.

Do not forget to redeploy the preview to apply the changes.'); + + // Find the key for this preview in the collection + $previewKey = $this->application->previews->search(function ($item) use ($preview_id) { + return $item->id == $preview_id; + }); + + if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) { + $fqdn = $this->previewFqdns[$previewKey]; + + if (! empty($fqdn)) { + $fqdn = str($fqdn)->replaceEnd(',', '')->trim(); + $fqdn = str($fqdn)->replaceStart(',', '')->trim(); + $fqdn = str($fqdn)->trim()->lower(); + $this->previewFqdns[$previewKey] = $fqdn; + + if (! validateDNSEntry($fqdn, $this->application->destination->server)) { + $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.

$fqdn->{$this->application->destination->server->ip}

Check this documentation for further help."); + $success = false; + } + + // Check for domain conflicts if not forcing save + if (! $this->forceSaveDomains) { + $result = checkDomainUsage(resource: $this->application, domain: $fqdn); + if ($result['hasConflicts']) { + $this->domainConflicts = $result['conflicts']; + $this->showDomainConflictModal = true; + $this->pendingPreviewId = $preview_id; + + return; + } + } else { + // Reset the force flag after using it + $this->forceSaveDomains = false; + } + } + } + + if ($success) { + $this->syncData(true); + $preview->save(); + $this->dispatch('success', 'Preview saved.

Do not forget to redeploy the preview to apply the changes.'); + } } catch (\Throwable $e) { return handleError($e, $this); } @@ -121,6 +158,7 @@ public function generate_preview($preview_id) if ($this->application->build_pack === 'dockercompose') { $preview->generate_preview_fqdn_compose(); $this->application->refresh(); + $this->syncData(false); $this->dispatch('success', 'Domain generated.'); return; @@ -128,6 +166,7 @@ public function generate_preview($preview_id) $preview->generate_preview_fqdn(); $this->application->refresh(); + $this->syncData(false); $this->dispatch('update_links'); $this->dispatch('success', 'Domain generated.'); } catch (\Throwable $e) { @@ -152,6 +191,7 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null) } $found->generate_preview_fqdn_compose(); $this->application->refresh(); + $this->syncData(false); } else { $this->setDeploymentUuid(); $found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first(); @@ -164,6 +204,7 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null) } $found->generate_preview_fqdn(); $this->application->refresh(); + $this->syncData(false); $this->dispatch('update_links'); $this->dispatch('success', 'Preview added.'); } diff --git a/app/Livewire/Project/Application/PreviewsCompose.php b/app/Livewire/Project/Application/PreviewsCompose.php index cfb364b6d..942dfeb37 100644 --- a/app/Livewire/Project/Application/PreviewsCompose.php +++ b/app/Livewire/Project/Application/PreviewsCompose.php @@ -18,6 +18,13 @@ class PreviewsCompose extends Component public ApplicationPreview $preview; + public ?string $domain = null; + + public function mount() + { + $this->domain = data_get($this->service, 'domain'); + } + public function render() { return view('livewire.project.application.previews-compose'); @@ -28,10 +35,10 @@ public function save() try { $this->authorize('update', $this->preview->application); - $domain = data_get($this->service, 'domain'); $docker_compose_domains = data_get($this->preview, 'docker_compose_domains'); - $docker_compose_domains = json_decode($docker_compose_domains, true); - $docker_compose_domains[$this->serviceName]['domain'] = $domain; + $docker_compose_domains = json_decode($docker_compose_domains, true) ?: []; + $docker_compose_domains[$this->serviceName] = $docker_compose_domains[$this->serviceName] ?? []; + $docker_compose_domains[$this->serviceName]['domain'] = $this->domain; $this->preview->docker_compose_domains = json_encode($docker_compose_domains); $this->preview->save(); $this->dispatch('update_links'); @@ -46,7 +53,7 @@ public function generate() try { $this->authorize('update', $this->preview->application); - $domains = collect(json_decode($this->preview->application->docker_compose_domains)) ?? collect(); + $domains = collect(json_decode($this->preview->application->docker_compose_domains, true) ?: []); $domain = $domains->first(function ($_, $key) { return $key === $this->serviceName; }); @@ -68,24 +75,40 @@ public function generate() $preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn; } else { // Use the existing domain from the main application - $url = Url::fromString($domain_string); + // Handle multiple domains separated by commas + $domain_list = explode(',', $domain_string); + $preview_fqdns = []; $template = $this->preview->application->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; $random = new Cuid2; - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); - $preview_fqdn = str_replace('{{port}}', $port, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn"; + + foreach ($domain_list as $single_domain) { + $single_domain = trim($single_domain); + if (empty($single_domain)) { + continue; + } + + $url = Url::fromString($single_domain); + $host = $url->getHost(); + $schema = $url->getScheme(); + $portInt = $url->getPort(); + $port = $portInt !== null ? ':'.$portInt : ''; + + $preview_fqdn = str_replace('{{random}}', $random, $template); + $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); + $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); + $preview_fqdn = str_replace('{{port}}', $port, $preview_fqdn); + $preview_fqdns[] = "$schema://$preview_fqdn"; + } + + $preview_fqdn = implode(',', $preview_fqdns); } // Save the generated domain + $this->domain = $preview_fqdn; $docker_compose_domains = data_get($this->preview, 'docker_compose_domains'); - $docker_compose_domains = json_decode($docker_compose_domains, true); - $docker_compose_domains[$this->serviceName]['domain'] = $this->service->domain = $preview_fqdn; + $docker_compose_domains = json_decode($docker_compose_domains, true) ?: []; + $docker_compose_domains[$this->serviceName] = $docker_compose_domains[$this->serviceName] ?? []; + $docker_compose_domains[$this->serviceName]['domain'] = $this->domain; $this->preview->docker_compose_domains = json_encode($docker_compose_domains); $this->preview->save(); diff --git a/app/Livewire/Project/Database/Configuration.php b/app/Livewire/Project/Database/Configuration.php index 513ba9f16..7c64a6eef 100644 --- a/app/Livewire/Project/Database/Configuration.php +++ b/app/Livewire/Project/Database/Configuration.php @@ -9,6 +9,7 @@ class Configuration extends Component { use AuthorizesRequests; + public $currentRoute; public $database; diff --git a/app/Livewire/Project/Service/Database.php b/app/Livewire/Project/Service/Database.php index abf4c45a7..4bcf866d3 100644 --- a/app/Livewire/Project/Service/Database.php +++ b/app/Livewire/Project/Service/Database.php @@ -24,16 +24,30 @@ class Database extends Component 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 = [ - 'database.human_name' => 'nullable', - 'database.description' => 'nullable', - 'database.image' => 'required', - 'database.exclude_from_status' => 'required|boolean', - 'database.public_port' => 'nullable|integer', - 'database.is_public' => 'required|boolean', - 'database.is_log_drain_enabled' => 'required|boolean', + 'humanName' => 'nullable', + 'description' => 'nullable', + 'image' => 'required', + 'excludeFromStatus' => 'required|boolean', + 'publicPort' => 'nullable|integer', + 'isPublic' => 'required|boolean', + 'isLogDrainEnabled' => 'required|boolean', ]; public function render() @@ -50,11 +64,33 @@ public function mount() $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 { @@ -92,7 +128,7 @@ public function instantSaveLogDrain() try { $this->authorize('update', $this->database); if (! $this->database->service->destination->server->isLogDrainEnabled()) { - $this->database->is_log_drain_enabled = false; + $this->isLogDrainEnabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); return; @@ -145,15 +181,17 @@ public function instantSave() { try { $this->authorize('update', $this->database); - if ($this->database->is_public && ! $this->database->public_port) { + if ($this->isPublic && ! $this->publicPort) { $this->dispatch('error', 'Public port is required.'); - $this->database->is_public = false; + $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; @@ -182,7 +220,10 @@ 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) { diff --git a/app/Livewire/Project/Service/EditCompose.php b/app/Livewire/Project/Service/EditCompose.php index b5f208941..32cf72067 100644 --- a/app/Livewire/Project/Service/EditCompose.php +++ b/app/Livewire/Project/Service/EditCompose.php @@ -11,6 +11,12 @@ class EditCompose extends Component public $serviceId; + public ?string $dockerComposeRaw = null; + + public ?string $dockerCompose = null; + + public bool $isContainerLabelEscapeEnabled = false; + protected $listeners = [ 'refreshEnvs', 'envsUpdated', @@ -18,30 +24,45 @@ class EditCompose extends Component ]; protected $rules = [ - 'service.docker_compose_raw' => 'required', - 'service.docker_compose' => 'required', - 'service.is_container_label_escape_enabled' => 'required', + 'dockerComposeRaw' => 'required', + 'dockerCompose' => 'required', + 'isContainerLabelEscapeEnabled' => 'required', ]; public function envsUpdated() { - $this->dispatch('saveCompose', $this->service->docker_compose_raw); + $this->dispatch('saveCompose', $this->dockerComposeRaw); $this->refreshEnvs(); } public function refreshEnvs() { $this->service = Service::ownedByCurrentTeam()->find($this->serviceId); + $this->syncData(false); } public function mount() { $this->service = Service::ownedByCurrentTeam()->find($this->serviceId); + $this->syncData(false); + } + + private function syncData(bool $toModel = false): void + { + if ($toModel) { + $this->service->docker_compose_raw = $this->dockerComposeRaw; + $this->service->docker_compose = $this->dockerCompose; + $this->service->is_container_label_escape_enabled = $this->isContainerLabelEscapeEnabled; + } else { + $this->dockerComposeRaw = $this->service->docker_compose_raw; + $this->dockerCompose = $this->service->docker_compose; + $this->isContainerLabelEscapeEnabled = $this->service->is_container_label_escape_enabled ?? false; + } } public function validateCompose() { - $isValid = validateComposeFile($this->service->docker_compose_raw, $this->service->server_id); + $isValid = validateComposeFile($this->dockerComposeRaw, $this->service->server_id); if ($isValid !== 'OK') { $this->dispatch('error', "Invalid docker-compose file.\n$isValid"); } else { @@ -52,16 +73,17 @@ public function validateCompose() public function saveEditedCompose() { $this->dispatch('info', 'Saving new docker compose...'); - $this->dispatch('saveCompose', $this->service->docker_compose_raw); + $this->dispatch('saveCompose', $this->dockerComposeRaw); $this->dispatch('refreshStorages'); } public function instantSave() { $this->validate([ - 'service.is_container_label_escape_enabled' => 'required', + 'isContainerLabelEscapeEnabled' => 'required', ]); - $this->service->save(['is_container_label_escape_enabled' => $this->service->is_container_label_escape_enabled]); + $this->syncData(true); + $this->service->save(['is_container_label_escape_enabled' => $this->isContainerLabelEscapeEnabled]); $this->dispatch('success', 'Service updated successfully'); } diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php index dbbcca8f8..43d885238 100644 --- a/app/Livewire/Project/Service/EditDomain.php +++ b/app/Livewire/Project/Service/EditDomain.php @@ -2,12 +2,14 @@ namespace App\Livewire\Project\Service; +use App\Livewire\Concerns\SynchronizesModelData; use App\Models\ServiceApplication; use Livewire\Component; use Spatie\Url\Url; class EditDomain extends Component { + use SynchronizesModelData; public $applicationId; public ServiceApplication $application; @@ -18,14 +20,24 @@ class EditDomain extends Component public $forceSaveDomains = false; + public ?string $fqdn = null; + protected $rules = [ - 'application.fqdn' => 'nullable', - 'application.required_fqdn' => 'required|boolean', + 'fqdn' => 'nullable', ]; public function mount() { - $this->application = ServiceApplication::find($this->applicationId); + $this->application = ServiceApplication::query()->findOrFail($this->applicationId); + $this->authorize('view', $this->application); + $this->syncFromModel(); + } + + protected function getModelBindings(): array + { + return [ + 'fqdn' => 'application.fqdn', + ]; } public function confirmDomainUsage() @@ -38,19 +50,22 @@ public function confirmDomainUsage() public function submit() { try { - $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); - $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); - $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { + $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->application->fqdn = $this->application->fqdn->unique()->implode(','); - $warning = sslipDomainWarning($this->application->fqdn); + $this->fqdn = $domains->unique()->implode(','); + $warning = sslipDomainWarning($this->fqdn); if ($warning) { $this->dispatch('warning', __('warning.sslipdomain')); } + // Sync to model for domain conflict check + $this->syncToModel(); // Check for domain conflicts if not forcing save if (! $this->forceSaveDomains) { $result = checkDomainUsage(resource: $this->application); @@ -67,6 +82,8 @@ public function submit() $this->validate(); $this->application->save(); + $this->application->refresh(); + $this->syncData(false); updateCompose($this->application); if (str($this->application->fqdn)->contains(',')) { $this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.

Only use multiple domains if you know what you are doing.'); @@ -79,6 +96,7 @@ public function submit() $originalFqdn = $this->application->getOriginal('fqdn'); if ($originalFqdn !== $this->application->fqdn) { $this->application->fqdn = $originalFqdn; + $this->syncData(false); } return handleError($e, $this); diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 7f0caaba3..40539b13e 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Service; +use App\Livewire\Concerns\SynchronizesModelData; use App\Models\Application; use App\Models\InstanceSettings; use App\Models\LocalFileVolume; @@ -22,7 +23,7 @@ class FileStorage extends Component { - use AuthorizesRequests; + use AuthorizesRequests, SynchronizesModelData; public LocalFileVolume $fileStorage; @@ -36,12 +37,16 @@ class FileStorage extends Component public bool $isReadOnly = false; + public ?string $content = null; + + public bool $isBasedOnGit = false; + protected $rules = [ 'fileStorage.is_directory' => 'required', 'fileStorage.fs_path' => 'required', 'fileStorage.mount_path' => 'required', - 'fileStorage.content' => 'nullable', - 'fileStorage.is_based_on_git' => 'required|boolean', + 'content' => 'nullable', + 'isBasedOnGit' => 'required|boolean', ]; public function mount() @@ -56,6 +61,15 @@ public function mount() } $this->isReadOnly = $this->fileStorage->isReadOnlyVolume(); + $this->syncFromModel(); + } + + protected function getModelBindings(): array + { + return [ + 'content' => 'fileStorage.content', + 'isBasedOnGit' => 'fileStorage.is_based_on_git', + ]; } public function convertToDirectory() @@ -82,6 +96,7 @@ public function loadStorageOnServer() $this->authorize('update', $this->resource); $this->fileStorage->loadStorageOnServer(); + $this->syncFromModel(); $this->dispatch('success', 'File storage loaded from server.'); } catch (\Throwable $e) { return handleError($e, $this); @@ -148,14 +163,16 @@ public function submit() try { $this->validate(); if ($this->fileStorage->is_directory) { - $this->fileStorage->content = null; + $this->content = null; } + $this->syncToModel(); $this->fileStorage->save(); $this->fileStorage->saveStorageOnServer(); $this->dispatch('success', 'File updated.'); } catch (\Throwable $e) { $this->fileStorage->setRawAttributes($original); $this->fileStorage->save(); + $this->syncFromModel(); return handleError($e, $this); } diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php index e37b6ad86..20358218f 100644 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ b/app/Livewire/Project/Service/ServiceApplicationView.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Service; +use App\Livewire\Concerns\SynchronizesModelData; use App\Models\InstanceSettings; use App\Models\ServiceApplication; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -14,6 +15,7 @@ class ServiceApplicationView extends Component { use AuthorizesRequests; + use SynchronizesModelData; public ServiceApplication $application; @@ -29,16 +31,32 @@ class ServiceApplicationView extends Component public $forceSaveDomains = false; + public ?string $humanName = null; + + public ?string $description = null; + + public ?string $fqdn = null; + + public ?string $image = null; + + public bool $excludeFromStatus = false; + + public bool $isLogDrainEnabled = false; + + public bool $isGzipEnabled = false; + + public bool $isStripprefixEnabled = false; + protected $rules = [ - 'application.human_name' => 'nullable', - 'application.description' => 'nullable', - 'application.fqdn' => 'nullable', - 'application.image' => 'string|nullable', - 'application.exclude_from_status' => 'required|boolean', + 'humanName' => 'nullable', + 'description' => 'nullable', + 'fqdn' => 'nullable', + 'image' => 'string|nullable', + 'excludeFromStatus' => 'required|boolean', 'application.required_fqdn' => 'required|boolean', - 'application.is_log_drain_enabled' => 'nullable|boolean', - 'application.is_gzip_enabled' => 'nullable|boolean', - 'application.is_stripprefix_enabled' => 'nullable|boolean', + 'isLogDrainEnabled' => 'nullable|boolean', + 'isGzipEnabled' => 'nullable|boolean', + 'isStripprefixEnabled' => 'nullable|boolean', ]; public function instantSave() @@ -56,11 +74,12 @@ public function instantSaveAdvanced() try { $this->authorize('update', $this->application); if (! $this->application->service->destination->server->isLogDrainEnabled()) { - $this->application->is_log_drain_enabled = false; + $this->isLogDrainEnabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); return; } + $this->syncToModel(); $this->application->save(); $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); } catch (\Throwable $e) { @@ -95,11 +114,26 @@ public function mount() try { $this->parameters = get_route_parameters(); $this->authorize('view', $this->application); + $this->syncFromModel(); } catch (\Throwable $e) { return handleError($e, $this); } } + protected function getModelBindings(): array + { + return [ + 'humanName' => 'application.human_name', + 'description' => 'application.description', + 'fqdn' => 'application.fqdn', + 'image' => 'application.image', + 'excludeFromStatus' => 'application.exclude_from_status', + 'isLogDrainEnabled' => 'application.is_log_drain_enabled', + 'isGzipEnabled' => 'application.is_gzip_enabled', + 'isStripprefixEnabled' => 'application.is_stripprefix_enabled', + ]; + } + public function convertToDatabase() { try { @@ -146,19 +180,21 @@ public function submit() { try { $this->authorize('update', $this->application); - $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); - $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); - $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { + $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->application->fqdn = $this->application->fqdn->unique()->implode(','); - $warning = sslipDomainWarning($this->application->fqdn); + $this->fqdn = $domains->unique()->implode(','); + $warning = sslipDomainWarning($this->fqdn); if ($warning) { $this->dispatch('warning', __('warning.sslipdomain')); } + // Sync to model for domain conflict check + $this->syncToModel(); // Check for domain conflicts if not forcing save if (! $this->forceSaveDomains) { $result = checkDomainUsage(resource: $this->application); @@ -175,6 +211,8 @@ public function submit() $this->validate(); $this->application->save(); + $this->application->refresh(); + $this->syncFromModel(); updateCompose($this->application); if (str($this->application->fqdn)->contains(',')) { $this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.

Only use multiple domains if you know what you are doing.'); @@ -186,6 +224,7 @@ public function submit() $originalFqdn = $this->application->getOriginal('fqdn'); if ($originalFqdn !== $this->application->fqdn) { $this->application->fqdn = $originalFqdn; + $this->syncFromModel(); } return handleError($e, $this); diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php index a0d2699ba..85cd21a7f 100644 --- a/app/Livewire/Project/Service/StackForm.php +++ b/app/Livewire/Project/Service/StackForm.php @@ -15,14 +15,25 @@ class StackForm extends Component protected $listeners = ['saveCompose']; + // Explicit properties + public string $name; + + public ?string $description = null; + + public string $dockerComposeRaw; + + public string $dockerCompose; + + public ?bool $connectToDockerNetwork = null; + protected function rules(): array { $baseRules = [ - 'service.docker_compose_raw' => 'required', - 'service.docker_compose' => 'required', - 'service.name' => ValidationPatterns::nameRules(), - 'service.description' => ValidationPatterns::descriptionRules(), - 'service.connect_to_docker_network' => 'nullable', + 'dockerComposeRaw' => 'required', + 'dockerCompose' => 'required', + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'connectToDockerNetwork' => 'nullable', ]; // Add dynamic field rules @@ -39,19 +50,44 @@ protected function messages(): array return array_merge( ValidationPatterns::combinedMessages(), [ - 'service.name.required' => 'The Name field is required.', - 'service.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', - 'service.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', - 'service.docker_compose_raw.required' => 'The Docker Compose Raw field is required.', - 'service.docker_compose.required' => 'The Docker Compose field is required.', + 'name.required' => 'The Name field is required.', + 'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'dockerComposeRaw.required' => 'The Docker Compose Raw field is required.', + 'dockerCompose.required' => 'The Docker Compose field is required.', ] ); } public $validationAttributes = []; + /** + * Sync data between component properties and model + * + * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties. + */ + private function syncData(bool $toModel = false): void + { + if ($toModel) { + // Sync TO model (before save) + $this->service->name = $this->name; + $this->service->description = $this->description; + $this->service->docker_compose_raw = $this->dockerComposeRaw; + $this->service->docker_compose = $this->dockerCompose; + $this->service->connect_to_docker_network = $this->connectToDockerNetwork; + } else { + // Sync FROM model (on load/refresh) + $this->name = $this->service->name; + $this->description = $this->service->description; + $this->dockerComposeRaw = $this->service->docker_compose_raw; + $this->dockerCompose = $this->service->docker_compose; + $this->connectToDockerNetwork = $this->service->connect_to_docker_network; + } + } + public function mount() { + $this->syncData(false); $this->fields = collect([]); $extraFields = $this->service->extraFields(); foreach ($extraFields as $serviceName => $fields) { @@ -87,12 +123,13 @@ public function mount() public function saveCompose($raw) { - $this->service->docker_compose_raw = $raw; + $this->dockerComposeRaw = $raw; $this->submit(notify: true); } public function instantSave() { + $this->syncData(true); $this->service->save(); $this->dispatch('success', 'Service settings saved.'); } @@ -101,6 +138,7 @@ public function submit($notify = true) { try { $this->validate(); + $this->syncData(true); // Validate for command injection BEFORE saving to database validateDockerComposeForInjection($this->service->docker_compose_raw); diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index c0714fe03..c8029761d 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -2,35 +2,90 @@ namespace App\Livewire\Project\Shared; +use App\Livewire\Concerns\SynchronizesModelData; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class HealthChecks extends Component { use AuthorizesRequests; + use SynchronizesModelData; public $resource; - protected $rules = [ - 'resource.health_check_enabled' => 'boolean', - 'resource.health_check_path' => 'string', - 'resource.health_check_port' => 'nullable|string', - 'resource.health_check_host' => 'string', - 'resource.health_check_method' => 'string', - 'resource.health_check_return_code' => 'integer', - 'resource.health_check_scheme' => 'string', - 'resource.health_check_response_text' => 'nullable|string', - 'resource.health_check_interval' => 'integer|min:1', - 'resource.health_check_timeout' => 'integer|min:1', - 'resource.health_check_retries' => 'integer|min:1', - 'resource.health_check_start_period' => 'integer', - 'resource.custom_healthcheck_found' => 'boolean', + // Explicit properties + public bool $healthCheckEnabled = false; + public string $healthCheckMethod; + + public string $healthCheckScheme; + + public string $healthCheckHost; + + public ?string $healthCheckPort = null; + + public string $healthCheckPath; + + public int $healthCheckReturnCode; + + public ?string $healthCheckResponseText = null; + + public int $healthCheckInterval; + + public int $healthCheckTimeout; + + public int $healthCheckRetries; + + public int $healthCheckStartPeriod; + + public bool $customHealthcheckFound = false; + + protected $rules = [ + 'healthCheckEnabled' => 'boolean', + 'healthCheckPath' => 'string', + 'healthCheckPort' => 'nullable|string', + 'healthCheckHost' => 'string', + 'healthCheckMethod' => 'string', + 'healthCheckReturnCode' => 'integer', + 'healthCheckScheme' => 'string', + 'healthCheckResponseText' => 'nullable|string', + 'healthCheckInterval' => 'integer|min:1', + 'healthCheckTimeout' => 'integer|min:1', + 'healthCheckRetries' => 'integer|min:1', + 'healthCheckStartPeriod' => 'integer', + 'customHealthcheckFound' => 'boolean', ]; + protected function getModelBindings(): array + { + return [ + 'healthCheckEnabled' => 'resource.health_check_enabled', + 'healthCheckMethod' => 'resource.health_check_method', + 'healthCheckScheme' => 'resource.health_check_scheme', + 'healthCheckHost' => 'resource.health_check_host', + 'healthCheckPort' => 'resource.health_check_port', + 'healthCheckPath' => 'resource.health_check_path', + 'healthCheckReturnCode' => 'resource.health_check_return_code', + 'healthCheckResponseText' => 'resource.health_check_response_text', + 'healthCheckInterval' => 'resource.health_check_interval', + 'healthCheckTimeout' => 'resource.health_check_timeout', + 'healthCheckRetries' => 'resource.health_check_retries', + 'healthCheckStartPeriod' => 'resource.health_check_start_period', + 'customHealthcheckFound' => 'resource.custom_healthcheck_found', + ]; + } + + public function mount() + { + $this->authorize('view', $this->resource); + $this->syncFromModel(); + } + public function instantSave() { $this->authorize('update', $this->resource); + + $this->syncToModel(); $this->resource->save(); $this->dispatch('success', 'Health check updated.'); } @@ -40,6 +95,8 @@ public function submit() try { $this->authorize('update', $this->resource); $this->validate(); + + $this->syncToModel(); $this->resource->save(); $this->dispatch('success', 'Health check updated.'); } catch (\Throwable $e) { @@ -51,14 +108,16 @@ public function toggleHealthcheck() { try { $this->authorize('update', $this->resource); - $wasEnabled = $this->resource->health_check_enabled; - $this->resource->health_check_enabled = ! $this->resource->health_check_enabled; + $wasEnabled = $this->healthCheckEnabled; + $this->healthCheckEnabled = ! $this->healthCheckEnabled; + + $this->syncToModel(); $this->resource->save(); - if ($this->resource->health_check_enabled && ! $wasEnabled && $this->resource->isRunning()) { + if ($this->healthCheckEnabled && ! $wasEnabled && $this->resource->isRunning()) { $this->dispatch('info', 'Health check has been enabled. A restart is required to apply the new settings.'); } else { - $this->dispatch('success', 'Health check '.($this->resource->health_check_enabled ? 'enabled' : 'disabled').'.'); + $this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'.'); } } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Shared/ResourceLimits.php b/app/Livewire/Project/Shared/ResourceLimits.php index 196badec8..0b3840289 100644 --- a/app/Livewire/Project/Shared/ResourceLimits.php +++ b/app/Livewire/Project/Shared/ResourceLimits.php @@ -11,52 +11,105 @@ class ResourceLimits extends Component public $resource; + // Explicit properties + public ?string $limitsCpus = null; + + public ?string $limitsCpuset = null; + + public ?int $limitsCpuShares = null; + + public string $limitsMemory; + + public string $limitsMemorySwap; + + public int $limitsMemorySwappiness; + + public string $limitsMemoryReservation; + protected $rules = [ - 'resource.limits_memory' => 'required|string', - 'resource.limits_memory_swap' => 'required|string', - 'resource.limits_memory_swappiness' => 'required|integer|min:0|max:100', - 'resource.limits_memory_reservation' => 'required|string', - 'resource.limits_cpus' => 'nullable', - 'resource.limits_cpuset' => 'nullable', - 'resource.limits_cpu_shares' => 'nullable', + 'limitsMemory' => 'required|string', + 'limitsMemorySwap' => 'required|string', + 'limitsMemorySwappiness' => 'required|integer|min:0|max:100', + 'limitsMemoryReservation' => 'required|string', + 'limitsCpus' => 'nullable', + 'limitsCpuset' => 'nullable', + 'limitsCpuShares' => 'nullable', ]; protected $validationAttributes = [ - 'resource.limits_memory' => 'memory', - 'resource.limits_memory_swap' => 'swap', - 'resource.limits_memory_swappiness' => 'swappiness', - 'resource.limits_memory_reservation' => 'reservation', - 'resource.limits_cpus' => 'cpus', - 'resource.limits_cpuset' => 'cpuset', - 'resource.limits_cpu_shares' => 'cpu shares', + 'limitsMemory' => 'memory', + 'limitsMemorySwap' => 'swap', + 'limitsMemorySwappiness' => 'swappiness', + 'limitsMemoryReservation' => 'reservation', + 'limitsCpus' => 'cpus', + 'limitsCpuset' => 'cpuset', + 'limitsCpuShares' => 'cpu shares', ]; + /** + * Sync data between component properties and model + * + * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties. + */ + private function syncData(bool $toModel = false): void + { + if ($toModel) { + // Sync TO model (before save) + $this->resource->limits_cpus = $this->limitsCpus; + $this->resource->limits_cpuset = $this->limitsCpuset; + $this->resource->limits_cpu_shares = $this->limitsCpuShares; + $this->resource->limits_memory = $this->limitsMemory; + $this->resource->limits_memory_swap = $this->limitsMemorySwap; + $this->resource->limits_memory_swappiness = $this->limitsMemorySwappiness; + $this->resource->limits_memory_reservation = $this->limitsMemoryReservation; + } else { + // Sync FROM model (on load/refresh) + $this->limitsCpus = $this->resource->limits_cpus; + $this->limitsCpuset = $this->resource->limits_cpuset; + $this->limitsCpuShares = $this->resource->limits_cpu_shares; + $this->limitsMemory = $this->resource->limits_memory; + $this->limitsMemorySwap = $this->resource->limits_memory_swap; + $this->limitsMemorySwappiness = $this->resource->limits_memory_swappiness; + $this->limitsMemoryReservation = $this->resource->limits_memory_reservation; + } + } + + public function mount() + { + $this->syncData(false); + } + public function submit() { try { $this->authorize('update', $this->resource); - if (! $this->resource->limits_memory) { - $this->resource->limits_memory = '0'; + + // Apply default values to properties + if (! $this->limitsMemory) { + $this->limitsMemory = '0'; } - if (! $this->resource->limits_memory_swap) { - $this->resource->limits_memory_swap = '0'; + if (! $this->limitsMemorySwap) { + $this->limitsMemorySwap = '0'; } - if (is_null($this->resource->limits_memory_swappiness)) { - $this->resource->limits_memory_swappiness = '60'; + if (is_null($this->limitsMemorySwappiness)) { + $this->limitsMemorySwappiness = 60; } - if (! $this->resource->limits_memory_reservation) { - $this->resource->limits_memory_reservation = '0'; + if (! $this->limitsMemoryReservation) { + $this->limitsMemoryReservation = '0'; } - if (! $this->resource->limits_cpus) { - $this->resource->limits_cpus = '0'; + if (! $this->limitsCpus) { + $this->limitsCpus = '0'; } - if ($this->resource->limits_cpuset === '') { - $this->resource->limits_cpuset = null; + if ($this->limitsCpuset === '') { + $this->limitsCpuset = null; } - if (is_null($this->resource->limits_cpu_shares)) { - $this->resource->limits_cpu_shares = 1024; + if (is_null($this->limitsCpuShares)) { + $this->limitsCpuShares = 1024; } + $this->validate(); + + $this->syncData(true); $this->resource->save(); $this->dispatch('success', 'Resource limits updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 4f57cbfa6..5970ec904 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -25,20 +25,48 @@ class Show extends Component public ?string $startedAt = null; + // Explicit properties + public string $name; + + public string $mountPath; + + public ?string $hostPath = null; + protected $rules = [ - 'storage.name' => 'required|string', - 'storage.mount_path' => 'required|string', - 'storage.host_path' => 'string|nullable', + 'name' => 'required|string', + 'mountPath' => 'required|string', + 'hostPath' => 'string|nullable', ]; protected $validationAttributes = [ 'name' => 'name', - 'mount_path' => 'mount', - 'host_path' => 'host', + 'mountPath' => 'mount', + 'hostPath' => 'host', ]; + /** + * Sync data between component properties and model + * + * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties. + */ + private function syncData(bool $toModel = false): void + { + if ($toModel) { + // Sync TO model (before save) + $this->storage->name = $this->name; + $this->storage->mount_path = $this->mountPath; + $this->storage->host_path = $this->hostPath; + } else { + // Sync FROM model (on load/refresh) + $this->name = $this->storage->name; + $this->mountPath = $this->storage->mount_path; + $this->hostPath = $this->storage->host_path; + } + } + public function mount() { + $this->syncData(false); $this->isReadOnly = $this->storage->isReadOnlyVolume(); } @@ -47,6 +75,7 @@ public function submit() $this->authorize('update', $this->resource); $this->validate(); + $this->syncData(true); $this->storage->save(); $this->dispatch('success', 'Storage updated successfully'); } diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php index 2ff06c349..9928cfe97 100644 --- a/app/Livewire/Security/PrivateKey/Show.php +++ b/app/Livewire/Security/PrivateKey/Show.php @@ -13,15 +13,24 @@ class Show extends Component public PrivateKey $private_key; + // Explicit properties + public string $name; + + public ?string $description = null; + + public string $privateKeyValue; + + public bool $isGitRelated = false; + public $public_key = 'Loading...'; protected function rules(): array { return [ - 'private_key.name' => ValidationPatterns::nameRules(), - 'private_key.description' => ValidationPatterns::descriptionRules(), - 'private_key.private_key' => 'required|string', - 'private_key.is_git_related' => 'nullable|boolean', + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'privateKeyValue' => 'required|string', + 'isGitRelated' => 'nullable|boolean', ]; } @@ -30,25 +39,48 @@ protected function messages(): array return array_merge( ValidationPatterns::combinedMessages(), [ - 'private_key.name.required' => 'The Name field is required.', - 'private_key.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', - 'private_key.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', - 'private_key.private_key.required' => 'The Private Key field is required.', - 'private_key.private_key.string' => 'The Private Key must be a valid string.', + 'name.required' => 'The Name field is required.', + 'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'privateKeyValue.required' => 'The Private Key field is required.', + 'privateKeyValue.string' => 'The Private Key must be a valid string.', ] ); } protected $validationAttributes = [ - 'private_key.name' => 'name', - 'private_key.description' => 'description', - 'private_key.private_key' => 'private key', + 'name' => 'name', + 'description' => 'description', + 'privateKeyValue' => 'private key', ]; + /** + * Sync data between component properties and model + * + * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties. + */ + private function syncData(bool $toModel = false): void + { + if ($toModel) { + // Sync TO model (before save) + $this->private_key->name = $this->name; + $this->private_key->description = $this->description; + $this->private_key->private_key = $this->privateKeyValue; + $this->private_key->is_git_related = $this->isGitRelated; + } else { + // Sync FROM model (on load/refresh) + $this->name = $this->private_key->name; + $this->description = $this->private_key->description; + $this->privateKeyValue = $this->private_key->private_key; + $this->isGitRelated = $this->private_key->is_git_related; + } + } + public function mount() { try { $this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related'])->whereUuid(request()->private_key_uuid)->firstOrFail(); + $this->syncData(false); } catch (\Throwable) { abort(404); } @@ -81,6 +113,10 @@ public function changePrivateKey() { try { $this->authorize('update', $this->private_key); + + $this->validate(); + + $this->syncData(true); $this->private_key->updatePrivateKey([ 'private_key' => formatPrivateKey($this->private_key->private_key), ]); diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index a77c5df78..ca5c588f8 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -290,7 +290,7 @@ private function loadHetznerData(string $token) } } - private function getCpuVendorInfo(array $serverType): string|null + private function getCpuVendorInfo(array $serverType): ?string { $name = strtolower($serverType['name'] ?? ''); diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 5ef559862..bc7e9bde4 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -22,6 +22,8 @@ class Proxy extends Component public ?string $redirectUrl = null; + public bool $generateExactLabels = false; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -33,7 +35,7 @@ public function getListeners() } protected $rules = [ - 'server.settings.generate_exact_labels' => 'required|boolean', + 'generateExactLabels' => 'required|boolean', ]; public function mount() @@ -41,6 +43,16 @@ public function mount() $this->selectedProxy = $this->server->proxyType(); $this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true); $this->redirectUrl = data_get($this->server, 'proxy.redirect_url'); + $this->syncData(false); + } + + private function syncData(bool $toModel = false): void + { + if ($toModel) { + $this->server->settings->generate_exact_labels = $this->generateExactLabels; + } else { + $this->generateExactLabels = $this->server->settings->generate_exact_labels ?? false; + } } public function getConfigurationFilePathProperty() @@ -75,6 +87,7 @@ public function instantSave() try { $this->authorize('update', $this->server); $this->validate(); + $this->syncData(true); $this->server->settings->save(); $this->dispatch('success', 'Settings saved.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 9ad5444b9..351407dac 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -34,32 +34,60 @@ class Change extends Component public ?GithubApp $github_app = null; + // Explicit properties public string $name; - public bool $is_system_wide; + public ?string $organization = null; + + public string $apiUrl; + + public string $htmlUrl; + + public string $customUser; + + public int $customPort; + + public int $appId; + + public int $installationId; + + public string $clientId; + + public string $clientSecret; + + public string $webhookSecret; + + public bool $isSystemWide; + + public int $privateKeyId; + + public ?string $contents = null; + + public ?string $metadata = null; + + public ?string $pullRequests = null; public $applications; public $privateKeys; protected $rules = [ - 'github_app.name' => 'required|string', - 'github_app.organization' => 'nullable|string', - 'github_app.api_url' => 'required|string', - 'github_app.html_url' => 'required|string', - 'github_app.custom_user' => 'required|string', - 'github_app.custom_port' => 'required|int', - 'github_app.app_id' => 'required|int', - 'github_app.installation_id' => 'required|int', - 'github_app.client_id' => 'required|string', - 'github_app.client_secret' => 'required|string', - 'github_app.webhook_secret' => 'required|string', - 'github_app.is_system_wide' => 'required|bool', - 'github_app.contents' => 'nullable|string', - 'github_app.metadata' => 'nullable|string', - 'github_app.pull_requests' => 'nullable|string', - 'github_app.administration' => 'nullable|string', - 'github_app.private_key_id' => 'required|int', + 'name' => 'required|string', + 'organization' => 'nullable|string', + 'apiUrl' => 'required|string', + 'htmlUrl' => 'required|string', + 'customUser' => 'required|string', + 'customPort' => 'required|int', + 'appId' => 'required|int', + 'installationId' => 'required|int', + 'clientId' => 'required|string', + 'clientSecret' => 'required|string', + 'webhookSecret' => 'required|string', + 'isSystemWide' => 'required|bool', + 'contents' => 'nullable|string', + 'metadata' => 'nullable|string', + 'pullRequests' => 'nullable|string', + 'privateKeyId' => 'required|int', ]; public function boot() @@ -69,6 +97,52 @@ public function boot() } } + /** + * Sync data between component properties and model + * + * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties. + */ + private function syncData(bool $toModel = false): void + { + if ($toModel) { + // Sync TO model (before save) + $this->github_app->name = $this->name; + $this->github_app->organization = $this->organization; + $this->github_app->api_url = $this->apiUrl; + $this->github_app->html_url = $this->htmlUrl; + $this->github_app->custom_user = $this->customUser; + $this->github_app->custom_port = $this->customPort; + $this->github_app->app_id = $this->appId; + $this->github_app->installation_id = $this->installationId; + $this->github_app->client_id = $this->clientId; + $this->github_app->client_secret = $this->clientSecret; + $this->github_app->webhook_secret = $this->webhookSecret; + $this->github_app->is_system_wide = $this->isSystemWide; + $this->github_app->private_key_id = $this->privateKeyId; + $this->github_app->contents = $this->contents; + $this->github_app->metadata = $this->metadata; + $this->github_app->pull_requests = $this->pullRequests; + } else { + // Sync FROM model (on load/refresh) + $this->name = $this->github_app->name; + $this->organization = $this->github_app->organization; + $this->apiUrl = $this->github_app->api_url; + $this->htmlUrl = $this->github_app->html_url; + $this->customUser = $this->github_app->custom_user; + $this->customPort = $this->github_app->custom_port; + $this->appId = $this->github_app->app_id; + $this->installationId = $this->github_app->installation_id; + $this->clientId = $this->github_app->client_id; + $this->clientSecret = $this->github_app->client_secret; + $this->webhookSecret = $this->github_app->webhook_secret; + $this->isSystemWide = $this->github_app->is_system_wide; + $this->privateKeyId = $this->github_app->private_key_id; + $this->contents = $this->github_app->contents; + $this->metadata = $this->github_app->metadata; + $this->pullRequests = $this->github_app->pull_requests; + } + } + public function checkPermissions() { try { @@ -126,6 +200,10 @@ public function mount() $this->applications = $this->github_app->applications; $settings = instanceSettings(); + // Sync data from model to properties + $this->syncData(false); + + // Override name with kebab case for display $this->name = str($this->github_app->name)->kebab(); $this->fqdn = $settings->fqdn; @@ -247,21 +325,9 @@ public function submit() $this->authorize('update', $this->github_app); $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); - $this->validate([ - 'github_app.name' => 'required|string', - 'github_app.organization' => 'nullable|string', - 'github_app.api_url' => 'required|string', - 'github_app.html_url' => 'required|string', - 'github_app.custom_user' => 'required|string', - 'github_app.custom_port' => 'required|int', - 'github_app.app_id' => 'required|int', - 'github_app.installation_id' => 'required|int', - 'github_app.client_id' => 'required|string', - 'github_app.client_secret' => 'required|string', - 'github_app.webhook_secret' => 'required|string', - 'github_app.is_system_wide' => 'required|bool', - 'github_app.private_key_id' => 'required|int', - ]); + $this->validate(); + + $this->syncData(true); $this->github_app->save(); $this->dispatch('success', 'Github App updated.'); } catch (\Throwable $e) { @@ -286,6 +352,8 @@ public function instantSave() $this->authorize('update', $this->github_app); $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); + + $this->syncData(true); $this->github_app->save(); $this->dispatch('success', 'Github App updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index 9438b7727..d97550693 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -14,17 +14,34 @@ class Form extends Component public S3Storage $storage; + // Explicit properties + public ?string $name = null; + + public ?string $description = null; + + public string $endpoint; + + public string $bucket; + + public string $region; + + public string $key; + + public string $secret; + + public ?bool $isUsable = null; + protected function rules(): array { return [ - 'storage.is_usable' => 'nullable|boolean', - 'storage.name' => ValidationPatterns::nameRules(required: false), - 'storage.description' => ValidationPatterns::descriptionRules(), - 'storage.region' => 'required|max:255', - 'storage.key' => 'required|max:255', - 'storage.secret' => 'required|max:255', - 'storage.bucket' => 'required|max:255', - 'storage.endpoint' => 'required|url|max:255', + 'isUsable' => 'nullable|boolean', + 'name' => ValidationPatterns::nameRules(required: false), + 'description' => ValidationPatterns::descriptionRules(), + 'region' => 'required|max:255', + 'key' => 'required|max:255', + 'secret' => 'required|max:255', + 'bucket' => 'required|max:255', + 'endpoint' => 'required|url|max:255', ]; } @@ -33,34 +50,69 @@ protected function messages(): array return array_merge( ValidationPatterns::combinedMessages(), [ - 'storage.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', - 'storage.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', - 'storage.region.required' => 'The Region field is required.', - 'storage.region.max' => 'The Region may not be greater than 255 characters.', - 'storage.key.required' => 'The Access Key field is required.', - 'storage.key.max' => 'The Access Key may not be greater than 255 characters.', - 'storage.secret.required' => 'The Secret Key field is required.', - 'storage.secret.max' => 'The Secret Key may not be greater than 255 characters.', - 'storage.bucket.required' => 'The Bucket field is required.', - 'storage.bucket.max' => 'The Bucket may not be greater than 255 characters.', - 'storage.endpoint.required' => 'The Endpoint field is required.', - 'storage.endpoint.url' => 'The Endpoint must be a valid URL.', - 'storage.endpoint.max' => 'The Endpoint may not be greater than 255 characters.', + 'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'region.required' => 'The Region field is required.', + 'region.max' => 'The Region may not be greater than 255 characters.', + 'key.required' => 'The Access Key field is required.', + 'key.max' => 'The Access Key may not be greater than 255 characters.', + 'secret.required' => 'The Secret Key field is required.', + 'secret.max' => 'The Secret Key may not be greater than 255 characters.', + 'bucket.required' => 'The Bucket field is required.', + 'bucket.max' => 'The Bucket may not be greater than 255 characters.', + 'endpoint.required' => 'The Endpoint field is required.', + 'endpoint.url' => 'The Endpoint must be a valid URL.', + 'endpoint.max' => 'The Endpoint may not be greater than 255 characters.', ] ); } protected $validationAttributes = [ - 'storage.is_usable' => 'Is Usable', - 'storage.name' => 'Name', - 'storage.description' => 'Description', - 'storage.region' => 'Region', - 'storage.key' => 'Key', - 'storage.secret' => 'Secret', - 'storage.bucket' => 'Bucket', - 'storage.endpoint' => 'Endpoint', + 'isUsable' => 'Is Usable', + 'name' => 'Name', + 'description' => 'Description', + 'region' => 'Region', + 'key' => 'Key', + 'secret' => 'Secret', + 'bucket' => 'Bucket', + 'endpoint' => 'Endpoint', ]; + /** + * Sync data between component properties and model + * + * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties. + */ + private function syncData(bool $toModel = false): void + { + if ($toModel) { + // Sync TO model (before save) + $this->storage->name = $this->name; + $this->storage->description = $this->description; + $this->storage->endpoint = $this->endpoint; + $this->storage->bucket = $this->bucket; + $this->storage->region = $this->region; + $this->storage->key = $this->key; + $this->storage->secret = $this->secret; + $this->storage->is_usable = $this->isUsable; + } else { + // Sync FROM model (on load/refresh) + $this->name = $this->storage->name; + $this->description = $this->storage->description; + $this->endpoint = $this->storage->endpoint; + $this->bucket = $this->storage->bucket; + $this->region = $this->storage->region; + $this->key = $this->storage->key; + $this->secret = $this->storage->secret; + $this->isUsable = $this->storage->is_usable; + } + } + + public function mount() + { + $this->syncData(false); + } + public function testConnection() { try { @@ -94,6 +146,9 @@ public function submit() DB::transaction(function () { $this->validate(); + + // Sync properties to model before saving + $this->syncData(true); $this->storage->save(); // Test connection with new values - if this fails, transaction will rollback @@ -103,12 +158,16 @@ public function submit() $this->storage->is_usable = true; $this->storage->unusable_email_sent = false; $this->storage->save(); + + // Update local property to reflect success + $this->isUsable = true; }); $this->dispatch('success', 'Storage settings updated and connection verified.'); } catch (\Throwable $e) { // Refresh the model to revert UI to database values after rollback $this->storage->refresh(); + $this->syncData(false); return handleError($e, $this); } diff --git a/app/Livewire/Team/Index.php b/app/Livewire/Team/Index.php index 8b9b70e14..e4daad311 100644 --- a/app/Livewire/Team/Index.php +++ b/app/Livewire/Team/Index.php @@ -18,11 +18,16 @@ class Index extends Component public Team $team; + // Explicit properties + public string $name; + + public ?string $description = null; + protected function rules(): array { return [ - 'team.name' => ValidationPatterns::nameRules(), - 'team.description' => ValidationPatterns::descriptionRules(), + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), ]; } @@ -31,21 +36,40 @@ protected function messages(): array return array_merge( ValidationPatterns::combinedMessages(), [ - 'team.name.required' => 'The Name field is required.', - 'team.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', - 'team.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'name.required' => 'The Name field is required.', + 'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', ] ); } protected $validationAttributes = [ - 'team.name' => 'name', - 'team.description' => 'description', + 'name' => 'name', + 'description' => 'description', ]; + /** + * Sync data between component properties and model + * + * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties. + */ + private function syncData(bool $toModel = false): void + { + if ($toModel) { + // Sync TO model (before save) + $this->team->name = $this->name; + $this->team->description = $this->description; + } else { + // Sync FROM model (on load/refresh) + $this->name = $this->team->name; + $this->description = $this->team->description; + } + } + public function mount() { $this->team = currentTeam(); + $this->syncData(false); if (auth()->user()->isAdminFromSession()) { $this->invitations = TeamInvitation::whereTeamId(currentTeam()->id)->get(); @@ -62,6 +86,7 @@ public function submit() $this->validate(); try { $this->authorize('update', $this->team); + $this->syncData(true); $this->team->save(); refreshSession(); $this->dispatch('success', 'Team updated.'); diff --git a/app/View/Components/Forms/Checkbox.php b/app/View/Components/Forms/Checkbox.php index 88f858ec9..eb38d84af 100644 --- a/app/View/Components/Forms/Checkbox.php +++ b/app/View/Components/Forms/Checkbox.php @@ -6,9 +6,14 @@ use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Gate; use Illuminate\View\Component; +use Visus\Cuid2\Cuid2; class Checkbox extends Component { + public ?string $modelBinding = null; + + public ?string $htmlId = null; + /** * Create a new component instance. */ @@ -47,6 +52,18 @@ public function __construct( */ public function render(): View|Closure|string { + // Store original ID for wire:model binding (property name) + $this->modelBinding = $this->id; + + // Generate unique HTML ID by adding random suffix + // This prevents duplicate IDs when multiple forms are on the same page + if ($this->id) { + $uniqueSuffix = new Cuid2; + $this->htmlId = $this->id.'-'.$uniqueSuffix; + } else { + $this->htmlId = $this->id; + } + return view('components.forms.checkbox'); } } diff --git a/app/View/Components/Forms/Datalist.php b/app/View/Components/Forms/Datalist.php index 33e264e37..3b7a9ee34 100644 --- a/app/View/Components/Forms/Datalist.php +++ b/app/View/Components/Forms/Datalist.php @@ -10,6 +10,10 @@ class Datalist extends Component { + public ?string $modelBinding = null; + + public ?string $htmlId = null; + /** * Create a new component instance. */ @@ -47,11 +51,27 @@ public function __construct( */ public function render(): View|Closure|string { + // Store original ID for wire:model binding (property name) + $this->modelBinding = $this->id; + if (is_null($this->id)) { $this->id = new Cuid2; + // Don't create wire:model binding for auto-generated IDs + $this->modelBinding = 'null'; } + + // Generate unique HTML ID by adding random suffix + // This prevents duplicate IDs when multiple forms are on the same page + if ($this->modelBinding && $this->modelBinding !== 'null') { + // Use original ID with random suffix for uniqueness + $uniqueSuffix = new Cuid2; + $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix; + } else { + $this->htmlId = (string) $this->id; + } + if (is_null($this->name)) { - $this->name = $this->id; + $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id; } return view('components.forms.datalist'); diff --git a/app/View/Components/Forms/Input.php b/app/View/Components/Forms/Input.php index 83c98c0df..5ed347f42 100644 --- a/app/View/Components/Forms/Input.php +++ b/app/View/Components/Forms/Input.php @@ -10,6 +10,10 @@ class Input extends Component { + public ?string $modelBinding = null; + + public ?string $htmlId = null; + public function __construct( public ?string $id = null, public ?string $name = null, @@ -43,11 +47,26 @@ public function __construct( public function render(): View|Closure|string { + // Store original ID for wire:model binding (property name) + $this->modelBinding = $this->id; + if (is_null($this->id)) { $this->id = new Cuid2; + // Don't create wire:model binding for auto-generated IDs + $this->modelBinding = 'null'; } + // Generate unique HTML ID by adding random suffix + // This prevents duplicate IDs when multiple forms are on the same page + if ($this->modelBinding && $this->modelBinding !== 'null') { + // Use original ID with random suffix for uniqueness + $uniqueSuffix = new Cuid2; + $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix; + } else { + $this->htmlId = (string) $this->id; + } + if (is_null($this->name)) { - $this->name = $this->id; + $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id; } if ($this->type === 'password') { $this->defaultClass = $this->defaultClass.' pr-[2.8rem]'; diff --git a/app/View/Components/Forms/Select.php b/app/View/Components/Forms/Select.php index 49b69136b..026e3ba8c 100644 --- a/app/View/Components/Forms/Select.php +++ b/app/View/Components/Forms/Select.php @@ -10,6 +10,10 @@ class Select extends Component { + public ?string $modelBinding = null; + + public ?string $htmlId = null; + /** * Create a new component instance. */ @@ -40,11 +44,27 @@ public function __construct( */ public function render(): View|Closure|string { + // Store original ID for wire:model binding (property name) + $this->modelBinding = $this->id; + if (is_null($this->id)) { $this->id = new Cuid2; + // Don't create wire:model binding for auto-generated IDs + $this->modelBinding = 'null'; } + + // Generate unique HTML ID by adding random suffix + // This prevents duplicate IDs when multiple forms are on the same page + if ($this->modelBinding && $this->modelBinding !== 'null') { + // Use original ID with random suffix for uniqueness + $uniqueSuffix = new Cuid2; + $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix; + } else { + $this->htmlId = (string) $this->id; + } + if (is_null($this->name)) { - $this->name = $this->id; + $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id; } return view('components.forms.select'); diff --git a/app/View/Components/Forms/Textarea.php b/app/View/Components/Forms/Textarea.php index abf98e6df..a5303b947 100644 --- a/app/View/Components/Forms/Textarea.php +++ b/app/View/Components/Forms/Textarea.php @@ -10,6 +10,10 @@ class Textarea extends Component { + public ?string $modelBinding = null; + + public ?string $htmlId = null; + /** * Create a new component instance. */ @@ -54,11 +58,27 @@ public function __construct( */ public function render(): View|Closure|string { + // Store original ID for wire:model binding (property name) + $this->modelBinding = $this->id; + if (is_null($this->id)) { $this->id = new Cuid2; + // Don't create wire:model binding for auto-generated IDs + $this->modelBinding = 'null'; } + + // Generate unique HTML ID by adding random suffix + // This prevents duplicate IDs when multiple forms are on the same page + if ($this->modelBinding && $this->modelBinding !== 'null') { + // Use original ID with random suffix for uniqueness + $uniqueSuffix = new Cuid2; + $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix; + } else { + $this->htmlId = (string) $this->id; + } + if (is_null($this->name)) { - $this->name = $this->id; + $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id; } // $this->label = Str::title($this->label); diff --git a/config/livewire.php b/config/livewire.php index 02725e944..bd3733076 100644 --- a/config/livewire.php +++ b/config/livewire.php @@ -90,7 +90,7 @@ | */ - 'legacy_model_binding' => true, + 'legacy_model_binding' => false, /* |--------------------------------------------------------------------------- diff --git a/resources/views/components/forms/checkbox.blade.php b/resources/views/components/forms/checkbox.blade.php index 868f657f6..b291759a8 100644 --- a/resources/views/components/forms/checkbox.blade.php +++ b/resources/views/components/forms/checkbox.blade.php @@ -32,14 +32,14 @@ merge(['class' => $defaultClass]) }} wire:loading.attr="disabled" wire:click='{{ $instantSave === 'instantSave' || $instantSave == '1' ? 'instantSave' : $instantSave }}' - wire:model={{ $id }} @if ($checked) checked @endif /> + wire:model={{ $modelBinding }} id="{{ $htmlId }}" @if ($checked) checked @endif /> @else @if ($domValue) merge(['class' => $defaultClass]) }} - value={{ $domValue }} @if ($checked) checked @endif /> + value={{ $domValue }} id="{{ $htmlId }}" @if ($checked) checked @endif /> @else merge(['class' => $defaultClass]) }} - wire:model={{ $value ?? $id }} @if ($checked) checked @endif /> + wire:model={{ $value ?? $modelBinding }} id="{{ $htmlId }}" @if ($checked) checked @endif /> @endif @endif diff --git a/resources/views/components/forms/datalist.blade.php b/resources/views/components/forms/datalist.blade.php index 510f4adcc..5bb12aa8d 100644 --- a/resources/views/components/forms/datalist.blade.php +++ b/resources/views/components/forms/datalist.blade.php @@ -16,7 +16,7 @@
@endif -@error($id) +@error($modelBinding) diff --git a/resources/views/components/forms/input.blade.php b/resources/views/components/forms/input.blade.php index 2840d6561..6b88c7b44 100644 --- a/resources/views/components/forms/input.blade.php +++ b/resources/views/components/forms/input.blade.php @@ -27,9 +27,9 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov @endif merge(['class' => $defaultClass]) }} @required($required) - @if ($id !== 'null') wire:model={{ $id }} @endif - wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" wire:loading.attr="disabled" - type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}" + @if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif + wire:loading.attr="disabled" + type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}" name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}" aria-placeholder="{{ $attributes->get('placeholder') }}" @if ($autofocus) x-ref="autofocusInput" @endif> @@ -38,19 +38,19 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov @else merge(['class' => $defaultClass]) }} @required($required) @readonly($readonly) - @if ($id !== 'null') wire:model={{ $id }} @endif - wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" wire:loading.attr="disabled" + @if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif + wire:loading.attr="disabled" type="{{ $type }}" @disabled($disabled) min="{{ $attributes->get('min') }}" max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}" maxlength="{{ $attributes->get('maxlength') }}" - @if ($id !== 'null') id={{ $id }} @endif name="{{ $name }}" + @if ($htmlId !== 'null') id={{ $htmlId }} @endif name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}" @if ($autofocus) x-ref="autofocusInput" @endif> @endif @if (!$label && $helper) @endif - @error($id) + @error($modelBinding) diff --git a/resources/views/components/forms/select.blade.php b/resources/views/components/forms/select.blade.php index 13d500bc9..aac924f0d 100644 --- a/resources/views/components/forms/select.blade.php +++ b/resources/views/components/forms/select.blade.php @@ -11,11 +11,11 @@ class="flex gap-1 items-center mb-1 text-sm font-medium {{ $disabled ? 'text-neu @endif - @error($id) + @error($modelBinding) diff --git a/resources/views/components/forms/textarea.blade.php b/resources/views/components/forms/textarea.blade.php index 1093a150d..cee5faeda 100644 --- a/resources/views/components/forms/textarea.blade.php +++ b/resources/views/components/forms/textarea.blade.php @@ -25,8 +25,8 @@ function handleKeydown(e) { @endif @if ($useMonacoEditor) - @else @if ($type === 'password') @@ -45,19 +45,18 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer @endif merge(['class' => $defaultClassInput]) }} @required($required) - @if ($id !== 'null') wire:model={{ $id }} @endif - wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" wire:loading.attr="disabled" - type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}" + @if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif + wire:loading.attr="disabled" + type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}" name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}" aria-placeholder="{{ $attributes->get('placeholder') }}">
@@ -65,16 +64,15 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer @endif @endif - @error($id) + @error($modelBinding) diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 7cc22e5db..9ce846d3a 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -16,14 +16,14 @@
General configuration for your application.
- - + +
@if (!$application->dockerfile && $application->build_pack !== 'dockerimage')
- @@ -31,7 +31,7 @@ @if ($application->settings->is_static || $application->build_pack === 'static') - @@ -66,7 +66,7 @@
@endif @if ($application->settings->is_static || $application->build_pack === 'static') - @can('update', $application) @@ -77,13 +77,13 @@ @endif
@if ($application->could_set_build_commands()) - @endif @if ($application->settings->is_static && $application->build_pack !== 'static') @endif
@@ -121,7 +121,7 @@ x-bind:disabled="!canUpdate" /> @endif @else - @@ -164,15 +164,15 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@if ($application->build_pack === 'dockerimage') @if ($application->destination->server->isSwarm()) - - @else - - @endif @@ -181,18 +181,18 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" $application->destination->server->isSwarm() || $application->additional_servers->count() > 0 || $application->settings->is_build_server_enabled) - - @else - - @@ -206,20 +206,20 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" @else @if ($application->could_set_build_commands()) @if ($application->build_pack === 'nixpacks')
Nixpacks will detect the required configuration @@ -239,16 +239,16 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" @endcan
@@ -261,12 +261,12 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@@ -274,36 +274,36 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@endif
@else
- @if ($application->build_pack === 'dockerfile' && !$application->dockerfile) - @endif @if ($application->build_pack === 'dockerfile') - @endif @if ($application->could_set_build_commands()) @if ($application->settings->is_static) - @else - @endif @endif @@ -313,21 +313,21 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@endif @if ($application->build_pack !== 'dockercompose')
@endif @@ -344,18 +344,18 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" @endcan
@if ($application->settings->is_raw_compose_deployment_enabled) - @else @if ((int) $application->compose_parsing_version >= 3) - @endif - @@ -363,45 +363,45 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
{{-- --}} + id="is_container_label_readonly_enabled" instantSave> --}}
@endif @if ($application->dockerfile) - @endif @if ($application->build_pack !== 'dockercompose')

Network

@if ($application->settings->is_static || $application->build_pack === 'static') - @else @if ($application->settings->is_container_label_readonly_enabled === false) - @else - @endif @endif @if (!$application->destination->server->isSwarm()) - @endif @if (!$application->destination->server->isSwarm()) - + wire:model="custom_network_aliases" x-bind:disabled="!canUpdate" /> @endif
@@ -409,14 +409,14 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@if ($application->is_http_basic_auth_enabled)
- -
@endif @@ -432,11 +432,11 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@can('update', $application) @@ -455,21 +455,21 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"

Pre/Post Deployment Commands

@if ($application->build_pack === 'dockercompose') - @endif
@if ($application->build_pack === 'dockercompose') @endif
diff --git a/resources/views/livewire/project/application/previews-compose.blade.php b/resources/views/livewire/project/application/previews-compose.blade.php index ffed66814..ae8d70243 100644 --- a/resources/views/livewire/project/application/previews-compose.blade.php +++ b/resources/views/livewire/project/application/previews-compose.blade.php @@ -1,7 +1,7 @@
- + Save Generate Domain -
+ \ No newline at end of file diff --git a/resources/views/livewire/project/application/previews.blade.php b/resources/views/livewire/project/application/previews.blade.php index c2f634cd7..da75fb704 100644 --- a/resources/views/livewire/project/application/previews.blade.php +++ b/resources/views/livewire/project/application/previews.blade.php @@ -112,7 +112,7 @@ class="dark:text-warning">{{ $application->destination->server->name }}.<
+ id="previewFqdns.{{ $previewName }}" canGate="update" :canResource="$application"> @can('update', $application) Save Generate @@ -130,7 +130,7 @@ class="flex items-end gap-2 pt-4"> @else + id="previewFqdns.{{ $previewName }}" canGate="update" :canResource="$application"> @can('update', $application) Save Generate diff --git a/resources/views/livewire/project/service/database.blade.php b/resources/views/livewire/project/service/database.blade.php index 117ad44c5..1ebb3a44f 100644 --- a/resources/views/livewire/project/service/database.blade.php +++ b/resources/views/livewire/project/service/database.blade.php @@ -23,16 +23,16 @@
- - + + + label="Image" id="image">
- - +
@if ($db_url_public) + id="excludeFromStatus"> + instantSave="instantSaveLogDrain" id="isLogDrainEnabled" label="Drain Logs" />
diff --git a/resources/views/livewire/project/service/edit-compose.blade.php b/resources/views/livewire/project/service/edit-compose.blade.php index df0b857b5..313240849 100644 --- a/resources/views/livewire/project/service/edit-compose.blade.php +++ b/resources/views/livewire/project/service/edit-compose.blade.php @@ -6,24 +6,24 @@
- +
+ id="dockerComposeRaw">
- +
+ id="isContainerLabelEscapeEnabled" instantSave>
diff --git a/resources/views/livewire/project/service/edit-domain.blade.php b/resources/views/livewire/project/service/edit-domain.blade.php index 9d30957f0..a126eca5b 100644 --- a/resources/views/livewire/project/service/edit-domain.blade.php +++ b/resources/views/livewire/project/service/edit-domain.blade.php @@ -3,7 +3,7 @@
Note: If a service has a defined port, do not delete it.
If you want to use your custom domain, you can add it with a port.
Save diff --git a/resources/views/livewire/project/service/file-storage.blade.php b/resources/views/livewire/project/service/file-storage.blade.php index dc8f949fa..4ab966ec3 100644 --- a/resources/views/livewire/project/service/file-storage.blade.php +++ b/resources/views/livewire/project/service/file-storage.blade.php @@ -60,12 +60,12 @@ @if (data_get($resource, 'settings.is_preserve_repository_enabled'))
+ id="isBasedOnGit">
@endif @if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary) Save @@ -74,12 +74,12 @@ @if (data_get($resource, 'settings.is_preserve_repository_enabled'))
+ id="isBasedOnGit">
@endif + rows="20" id="content" disabled> @endcan @endif @else @@ -88,12 +88,12 @@ @if (data_get($resource, 'settings.is_preserve_repository_enabled'))
+ id="isBasedOnGit">
@endif + rows="20" id="content" disabled> @endif @endif diff --git a/resources/views/livewire/project/service/service-application-view.blade.php b/resources/views/livewire/project/service/service-application-view.blade.php index 4c8dbe61c..b95dc6540 100644 --- a/resources/views/livewire/project/service/service-application-view.blade.php +++ b/resources/views/livewire/project/service/service-application-view.blade.php @@ -23,48 +23,48 @@
- + id="description">
@if (!$application->serviceType()?->contains(str($application->image)->before(':'))) @if ($application->required_fqdn) @else @endif @endif + label="Image" id="image">

Advanced

@if (str($application->image)->contains('pocketbase')) - @else - @endif - + id="excludeFromStatus"> + instantSave="instantSaveAdvanced" id="isLogDrainEnabled" label="Drain Logs" />
diff --git a/resources/views/livewire/project/service/stack-form.blade.php b/resources/views/livewire/project/service/stack-form.blade.php index fff6524ce..5a8a3e420 100644 --- a/resources/views/livewire/project/service/stack-form.blade.php +++ b/resources/views/livewire/project/service/stack-form.blade.php @@ -15,11 +15,11 @@
Configuration
- - + +
-
@if ($fields->count() > 0) diff --git a/resources/views/livewire/project/shared/health-checks.blade.php b/resources/views/livewire/project/shared/health-checks.blade.php index ed64ff28e..730353c87 100644 --- a/resources/views/livewire/project/shared/health-checks.blade.php +++ b/resources/views/livewire/project/shared/health-checks.blade.php @@ -2,7 +2,7 @@

Healthchecks

Save - @if (!$resource->health_check_enabled) + @if (!$healthCheckEnabled)
Define how your resource's health should be checked.
- @if ($resource->custom_healthcheck_found) + @if ($customHealthcheckFound)

A custom health check has been detected. If you enable this health check, it will disable the custom one and use this instead.

@endif
- + - + - - + - +
- - +
- - - - +
diff --git a/resources/views/livewire/project/shared/resource-limits.blade.php b/resources/views/livewire/project/shared/resource-limits.blade.php index 2aa2fd0af..99ff249e9 100644 --- a/resources/views/livewire/project/shared/resource-limits.blade.php +++ b/resources/views/livewire/project/shared/resource-limits.blade.php @@ -9,32 +9,32 @@
+ label="Number of CPUs" id="limitsCpus" /> + label="CPU sets to use" id="limitsCpuset" /> + label="CPU Weight" id="limitsCpuShares" />

Limit Memory

+ label="Soft Memory Limit" id="limitsMemoryReservation" /> + id="limitsMemorySwappiness" />
+ label="Maximum Memory Limit" id="limitsMemory" /> + label="Maximum Swap Limit" id="limitsMemorySwap" />
diff --git a/resources/views/livewire/project/shared/storages/show.blade.php b/resources/views/livewire/project/shared/storages/show.blade.php index 798a97d94..6881e3b10 100644 --- a/resources/views/livewire/project/shared/storages/show.blade.php +++ b/resources/views/livewire/project/shared/storages/show.blade.php @@ -9,47 +9,47 @@ @if ( $storage->resource_type === 'App\Models\ServiceApplication' || $storage->resource_type === 'App\Models\ServiceDatabase') - @else - @endif @if ($isService || $startedAt) - - @else - - @endif
@else
- - - + + +
@endif @else @can('update', $resource) @if ($isFirst)
- - - + +
@else
- - - + + +
@endif
@@ -67,17 +67,17 @@ @else @if ($isFirst)
- - + -
@else
- - - + + +
@endif @endcan diff --git a/resources/views/livewire/security/private-key/show.blade.php b/resources/views/livewire/security/private-key/show.blade.php index 8668cfd34..7d90b5005 100644 --- a/resources/views/livewire/security/private-key/show.blade.php +++ b/resources/views/livewire/security/private-key/show.blade.php @@ -27,8 +27,8 @@
- - + +
@@ -46,17 +46,17 @@ Hide
- @if (data_get($private_key, 'is_git_related')) + @if ($isGitRelated)
- +
@endif
-
- +
diff --git a/resources/views/livewire/server/proxy.blade.php b/resources/views/livewire/server/proxy.blade.php index c46a114d8..46859095f 100644 --- a/resources/views/livewire/server/proxy.blade.php +++ b/resources/views/livewire/server/proxy.blade.php @@ -26,7 +26,7 @@
- + Sync Name @@ -64,41 +64,41 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans @endcan
- @if (!isCloud())
+ instantSave id="isSystemWide" />
@endif
- - + +
- -
- + id="installationId" label="Installation Id" required />
- - -
- @if (blank($github_app->private_key_id)) @@ -121,14 +121,14 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans @endcan
- - - {{-- --}} -
diff --git a/resources/views/livewire/storage/form.blade.php b/resources/views/livewire/storage/form.blade.php index 23892ec01..850d7735f 100644 --- a/resources/views/livewire/storage/form.blade.php +++ b/resources/views/livewire/storage/form.blade.php @@ -6,7 +6,7 @@
{{ $storage->name }}
Current Status:
- @if ($storage->is_usable) + @if ($isUsable) Usable @@ -32,19 +32,19 @@ class="px-2 py-1 text-xs font-semibold text-red-800 bg-red-100 rounded dark:text @endcan
- - + +
- - - + + +
+ id="key" /> + id="secret" />
@can('validateConnection', $storage) diff --git a/resources/views/livewire/team/index.blade.php b/resources/views/livewire/team/index.blade.php index 21cd0b622..041fa578c 100644 --- a/resources/views/livewire/team/index.blade.php +++ b/resources/views/livewire/team/index.blade.php @@ -11,8 +11,8 @@
- - + + @can('update', $team) Save