Merge pull request #6862 from coollabsio/andrasbacsai/livewire-model-binding

Complete Livewire legacy model binding migration (25+ components)
This commit is contained in:
Andras Bacsai authored and GitHub committed 2025-10-17 09:27:18 +02:00
commit dab30da63c
49 files changed
+1426 -555

No files matched your search

@@ -0,0 +1,35 @@
<?php
namespace App\Livewire\Concerns;
trait SynchronizesModelData
{
/**
* Define the mapping between component properties and model keys.
*
* @return array<string, string> 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);
}
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ class MonacoEditor extends Component
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,
+280 -120
View File
@@ -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 @@ use Visus\Cuid2\Cuid2;
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 @@ class General extends Component
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 @@ class General extends Component
$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 @@ class General extends Component
}
$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 @@ class General extends Component
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 @@ class General extends Component
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 @@ class General extends Component
$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 @@ class General extends Component
}
}
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 @@ class General extends Component
} 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 @@ class General extends Component
$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 @@ class General extends Component
$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 @@ class General extends Component
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 @@ class General extends Component
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 @@ class General extends Component
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 @@ class General extends Component
$this->forceSaveDomains = false;
}
$this->application->fqdn = $domains->implode(',');
$this->fqdn = $domains->implode(',');
$this->application->fqdn = $this->fqdn;
$this->resetDefaultLabels(false);
}
@@ -547,21 +697,27 @@ class General extends Component
$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 @@ class General extends Component
$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 @@ class General extends Component
}
$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 {
+67 -26
View File
@@ -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 @@ class Previews extends Component
$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.<br><br>$preview->fqdn->{$this->application->destination->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> 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.<br><br>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.<br><br>$fqdn->{$this->application->destination->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> 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.<br><br>Do not forget to redeploy the preview to apply the changes.');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -121,6 +158,7 @@ class Previews extends Component
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 @@ class Previews extends Component
$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 @@ class Previews extends Component
}
$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 @@ class Previews extends Component
}
$found->generate_preview_fqdn();
$this->application->refresh();
$this->syncData(false);
$this->dispatch('update_links');
$this->dispatch('success', 'Preview added.');
}
@@ -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 @@ class PreviewsCompose extends Component
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 @@ class PreviewsCompose extends Component
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 @@ class PreviewsCompose extends Component
$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();
@@ -9,6 +9,7 @@ use Livewire\Component;
class Configuration extends Component
{
use AuthorizesRequests;
public $currentRoute;
public $database;
+51 -10
View File
@@ -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 @@ class Database extends Component
$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 @@ class Database extends Component
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 @@ class Database extends Component
{
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 @@ class Database extends Component
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) {
+30 -8
View File
@@ -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 @@ class EditCompose extends Component
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');
}
+26 -8
View File
@@ -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 @@ class EditDomain extends Component
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 @@ class EditDomain extends Component
$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.<br><br>Only use multiple domains if you know what you are doing.');
@@ -79,6 +96,7 @@ class EditDomain extends Component
$originalFqdn = $this->application->getOriginal('fqdn');
if ($originalFqdn !== $this->application->fqdn) {
$this->application->fqdn = $originalFqdn;
$this->syncData(false);
}
return handleError($e, $this);
+21 -4
View File
@@ -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 @@ use Livewire\Component;
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 @@ class FileStorage extends Component
}
$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 @@ class FileStorage extends Component
$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 @@ class FileStorage extends Component
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);
}
@@ -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 @@ use Spatie\Url\Url;
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 @@ class ServiceApplicationView extends Component
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 @@ class ServiceApplicationView extends Component
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 @@ class ServiceApplicationView extends Component
{
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 @@ class ServiceApplicationView extends Component
$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.<br><br>Only use multiple domains if you know what you are doing.');
@@ -186,6 +224,7 @@ class ServiceApplicationView extends Component
$originalFqdn = $this->application->getOriginal('fqdn');
if ($originalFqdn !== $this->application->fqdn) {
$this->application->fqdn = $originalFqdn;
$this->syncFromModel();
}
return handleError($e, $this);
+49 -11
View File
@@ -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 @@ class StackForm extends Component
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 @@ class StackForm extends Component
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 @@ class StackForm extends Component
{
try {
$this->validate();
$this->syncData(true);
// Validate for command injection BEFORE saving to database
validateDockerComposeForInjection($this->service->docker_compose_raw);
+77 -18
View File
@@ -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 @@ class HealthChecks extends Component
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 @@ class HealthChecks extends Component
{
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);
+81 -28
View File
@@ -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) {
+34 -5
View File
@@ -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 @@ class Show extends Component
$this->authorize('update', $this->resource);
$this->validate();
$this->syncData(true);
$this->storage->save();
$this->dispatch('success', 'Storage updated successfully');
}
+48 -12
View File
@@ -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 @@ class Show extends Component
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 @@ class Show extends Component
{
try {
$this->authorize('update', $this->private_key);
$this->validate();
$this->syncData(true);
$this->private_key->updatePrivateKey([
'private_key' => formatPrivateKey($this->private_key->private_key),
]);
+1 -1
View File
@@ -290,7 +290,7 @@ class ByHetzner extends Component
}
}
private function getCpuVendorInfo(array $serverType): string|null
private function getCpuVendorInfo(array $serverType): ?string
{
$name = strtolower($serverType['name'] ?? '');
+14 -1
View File
@@ -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 @@ class Proxy extends Component
}
protected $rules = [
'server.settings.generate_exact_labels' => 'required|boolean',
'generateExactLabels' => 'required|boolean',
];
public function mount()
@@ -41,6 +43,16 @@ class Proxy extends Component
$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 @@ class Proxy extends Component
try {
$this->authorize('update', $this->server);
$this->validate();
$this->syncData(true);
$this->server->settings->save();
$this->dispatch('success', 'Settings saved.');
} catch (\Throwable $e) {
+101 -33
View File
@@ -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 @@ class Change extends Component
}
}
/**
* 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 @@ class Change extends Component
$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 @@ class Change extends Component
$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 @@ class Change extends Component
$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) {
+88 -29
View File
@@ -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 @@ class Form extends Component
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 @@ class Form extends Component
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 @@ class Form extends Component
$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);
}
+32 -7
View File
@@ -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 @@ class Index extends Component
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 @@ class Index extends Component
$this->validate();
try {
$this->authorize('update', $this->team);
$this->syncData(true);
$this->team->save();
refreshSession();
$this->dispatch('success', 'Team updated.');
+17
View File
@@ -6,9 +6,14 @@ use Closure;
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 @@ class Checkbox extends Component
*/
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');
}
}
+21 -1
View File
@@ -10,6 +10,10 @@ use Visus\Cuid2\Cuid2;
class Datalist extends Component
{
public ?string $modelBinding = null;
public ?string $htmlId = null;
/**
* Create a new component instance.
*/
@@ -47,11 +51,27 @@ class Datalist extends Component
*/
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');
+20 -1
View File
@@ -10,6 +10,10 @@ use Visus\Cuid2\Cuid2;
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 @@ class Input extends Component
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]';
+21 -1
View File
@@ -10,6 +10,10 @@ use Visus\Cuid2\Cuid2;
class Select extends Component
{
public ?string $modelBinding = null;
public ?string $htmlId = null;
/**
* Create a new component instance.
*/
@@ -40,11 +44,27 @@ class Select extends Component
*/
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');
+21 -1
View File
@@ -10,6 +10,10 @@ use Visus\Cuid2\Cuid2;
class Textarea extends Component
{
public ?string $modelBinding = null;
public ?string $htmlId = null;
/**
* Create a new component instance.
*/
@@ -54,11 +58,27 @@ class Textarea extends Component
*/
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);