diff --git a/.config/make/docker.mak b/.config/make/docker.mak index 3975949e57..36de6549e3 100644 --- a/.config/make/docker.mak +++ b/.config/make/docker.mak @@ -11,7 +11,7 @@ docker-lint: docker-lint-automation docker-lint-console-ui docker-lint-console-a docker-lint-automation: ## Lint automation Dockerfile @echo "Lint automation container Dockerfile" docker run --rm -i -v $(PWD)/automation/Dockerfile:/Dockerfile \ - hadolint/hadolint hadolint --ignore DL3002 --ignore DL3008 --ignore DL3059 /Dockerfile + hadolint/hadolint hadolint --ignore DL3002 --ignore DL3008 --ignore DL3013 --ignore DL3059 /Dockerfile docker-lint-console-ui: ## Lint console ui Dockerfile @echo "Lint console ui container Dockerfile" diff --git a/automation/Dockerfile b/automation/Dockerfile index 559ee498eb..e64ff572e4 100644 --- a/automation/Dockerfile +++ b/automation/Dockerfile @@ -13,18 +13,21 @@ COPY automation /autobase/automation RUN apt-get clean && rm -rf /var/lib/apt/lists/partial \ && apt-get update -o Acquire::CompressionTypes::Order::=gz \ && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \ - ca-certificates gnupg keychain debian-archive-keyring apt-transport-https \ - git python3 python3-dev python3-pip ssh-client sshpass gcc g++ cmake make libssl-dev curl lsb-release \ + ca-certificates gnupg keychain debian-archive-keyring apt-transport-https \ + git python3 python3-dev python3-pip ssh-client sshpass gcc g++ cmake make libssl-dev curl lsb-release \ + # fresh pip/setuptools/wheel (fewer builds from source) + && python3 -m pip install --break-system-packages --no-cache-dir --upgrade pip setuptools wheel \ # repo and key for Azure CLI && curl -sLS https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor | tee /etc/apt/trusted.gpg.d/microsoft.asc.gpg > /dev/null \ && echo "deb [arch=amd64] https://packages.microsoft.com/repos/azure-cli/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/azure-cli.list \ && apt-get update \ # requirements - && pip3 install --break-system-packages --no-cache-dir -r /autobase/automation/requirements.txt \ + && pip3 install --break-system-packages --no-cache-dir --retries 3 --timeout 60 \ + -r /autobase/automation/requirements.txt \ && ansible-galaxy install --force -r /autobase/automation/requirements.yml \ && ansible-galaxy collection list \ - && pip3 install --break-system-packages --no-cache-dir -r \ - /root/.ansible/collections/ansible_collections/azure/azcollection/requirements.txt \ + && pip3 install --break-system-packages --no-cache-dir --retries 3 --timeout 60 \ + -r /root/.ansible/collections/ansible_collections/azure/azcollection/requirements.txt \ # azure-cli && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y azure-cli \ # cleanup @@ -34,8 +37,8 @@ RUN apt-get clean && rm -rf /var/lib/apt/lists/partial \ && chmod +x /autobase/automation/entrypoint.sh # Link collection source for Ansible runtime -RUN mkdir -p /root/.ansible/collections/ansible_collections/vitabaks && \ - ln -sfn /autobase/automation /root/.ansible/collections/ansible_collections/vitabaks/autobase +RUN mkdir -p /root/.ansible/collections/ansible_collections/vitabaks \ + && ln -sfn /autobase/automation /root/.ansible/collections/ansible_collections/vitabaks/autobase # Set environment variables ENV ANSIBLE_COLLECTIONS_PATH=/root/.ansible/collections/ansible_collections:/usr/local/lib/python3.11/dist-packages/ansible_collections diff --git a/automation/inventory.example b/automation/inventory.example index 10e4e40169..121c215579 100644 --- a/automation/inventory.example +++ b/automation/inventory.example @@ -16,9 +16,9 @@ # if dcs_exists: false and dcs_type: "consul" [consul_instances] # recommendation: 3 or 5-7 nodes -#10.128.64.140 consul_node_role=server consul_bootstrap_expect=true consul_datacenter=dc1 -#10.128.64.142 consul_node_role=server consul_bootstrap_expect=true consul_datacenter=dc1 -#10.128.64.143 consul_node_role=server consul_bootstrap_expect=true consul_datacenter=dc1 +#10.128.64.140 consul_node_role=server consul_datacenter=dc1 +#10.128.64.142 consul_node_role=server consul_datacenter=dc1 +#10.128.64.143 consul_node_role=server consul_datacenter=dc1 #10.128.64.144 consul_node_role=client consul_datacenter=dc2 #10.128.64.145 consul_node_role=client consul_datacenter=dc2 diff --git a/automation/molecule/default/converge.yml b/automation/molecule/default/converge.yml index 7217dd803e..33f4b7b9fd 100644 --- a/automation/molecule/default/converge.yml +++ b/automation/molecule/default/converge.yml @@ -17,7 +17,6 @@ dcs_type: "{{ 'etcd' if ansible_distribution_major_version in ['10'] or ansible_distribution_release in ['trixie'] else (['etcd', 'consul'] | random) }}" # TODO: Consul support for RHEL 10, Debian 13 consul_node_role: server # if dcs_type: "consul" - consul_bootstrap_expect: true # if dcs_type: "consul" postgresql_version: 18 pgbouncer_processes: 2 # Test multiple pgbouncer processes (so_reuseport) patroni_tags: "datacenter=dc1,key1=value1" @@ -61,7 +60,7 @@ - name: Set variables for Extensions test ansible.builtin.set_fact: - enable_timescale: false # TODO: not available for PostgreSQL 18 + enable_timescale: "{{ true if ansible_distribution_major_version != '10' else false }}" # TODO: not available for PostgreSQL 18 on RHEL 10 enable_pg_repack: true enable_pg_cron: true enable_pgaudit: true @@ -69,9 +68,9 @@ enable_postgis: true enable_pgrouting: true enable_pg_wait_sampling: true - enable_pg_stat_kcache: false # TODO: not available for PostgreSQL 18 - enable_pg_partman: false # TODO: not available for PostgreSQL 18 - enable_pgvectorscale: false # TODO: not available for PostgreSQL 18 + enable_pg_stat_kcache: true + enable_pg_partman: true + enable_pgvectorscale: "{{ true if ansible_os_family == 'Debian' else false }}" # pgvectorscale packages are available only for Debian-based disros. # create extension postgresql_extensions: - { ext: "vector", db: "postgres" } diff --git a/automation/molecule/pg_upgrade/converge.yml b/automation/molecule/pg_upgrade/converge.yml index 4a412a4e03..4e29523686 100644 --- a/automation/molecule/pg_upgrade/converge.yml +++ b/automation/molecule/pg_upgrade/converge.yml @@ -17,7 +17,6 @@ dcs_type: "{{ 'etcd' if ansible_distribution_major_version in ['10'] or ansible_distribution_release in ['trixie'] else (['etcd', 'consul'] | random) }}" # TODO: Consul support for RHEL 10, Debian 13 consul_node_role: server # if dcs_type: "consul" - consul_bootstrap_expect: true # if dcs_type: "consul" postgresql_version: 17 # redefine the version to install for the upgrade test pgbouncer_processes: 4 # Test multiple pgbouncer processes (so_reuseport) cacheable: true @@ -27,7 +26,7 @@ # Extension Auto-Setup - name: Set variables for Extensions test ansible.builtin.set_fact: - enable_timescale: false # TODO: not available for PostgreSQL 18 + enable_timescale: "{{ true if ansible_distribution_major_version != '10' else false }}" # TODO: not available for PostgreSQL 18 on RHEL 10 enable_pg_repack: true enable_pg_cron: true enable_pgaudit: true @@ -35,9 +34,9 @@ enable_postgis: true enable_pgrouting: true enable_pg_wait_sampling: true - enable_pg_stat_kcache: false # TODO: not available for PostgreSQL 18 - enable_pg_partman: false # TODO: not available for PostgreSQL 18 - enable_pgvectorscale: false # TODO: not available for PostgreSQL 18 + enable_pg_stat_kcache: true + enable_pg_partman: true + enable_pgvectorscale: "{{ true if ansible_os_family == 'Debian' else false }}" # pgvectorscale packages are available only for Debian-based disros. # create extension postgresql_extensions: - { ext: "vector", db: "postgres" } diff --git a/automation/playbooks/consul_cluster.yml b/automation/playbooks/consul_cluster.yml index 8e1d719fcf..51dfd5065c 100644 --- a/automation/playbooks/consul_cluster.yml +++ b/automation/playbooks/consul_cluster.yml @@ -148,14 +148,6 @@ consul_dnsmasq_servers: "{{ consul_dnsmasq_servers | reject('equalto', '127.0.0.1') | list }}" when: dcs_type | default('etcd') == "consul" and consul_dnsmasq_enable | default(true) | bool and ('127.0.0.1' in (consul_dnsmasq_servers | default([]))) - # Setting variables for Consul during cloud deployment - - name: Redefine the consul_node_role and consul_bootstrap_expect variables - ansible.builtin.set_fact: - consul_node_role: "{{ 'server' if not dcs_exists | default(false) else 'client' }}" - consul_bootstrap_expect: "{{ not dcs_exists | default(false) }}" - consul_datacenter: "{{ server_location | default('dc1') }}" - when: cloud_provider | default('') | length > 0 - roles: - role: vitabaks.autobase.firewall vars: diff --git a/automation/playbooks/remove_cluster.yml b/automation/playbooks/remove_cluster.yml index 2efbd01573..b13e46d896 100644 --- a/automation/playbooks/remove_cluster.yml +++ b/automation/playbooks/remove_cluster.yml @@ -29,7 +29,7 @@ default_postgresql_cluster_name: "{{ 'main' if ansible_os_family == 'Debian' else 'data' }}" default_postgresql_data_dir: "\ {% if cloud_provider | default('') | length > 0 %}\ - {{ pg_data_mount_path | default('/pgdata') }}/{{ default_postgresql_version }}/{{ default_postgresql_cluster_name }}\ + {{ postgresql_data_dir_mount_path | default('/pgdata') }}/{{ default_postgresql_version }}/{{ default_postgresql_cluster_name }}\ {% else %}\ {{ default_postgresql_home_dir }}/{{ default_postgresql_version }}/{{ default_postgresql_cluster_name }}\ {% endif %}" diff --git a/automation/playbooks/remove_node.yml b/automation/playbooks/remove_node.yml index 62140c4700..e2ff850b56 100644 --- a/automation/playbooks/remove_node.yml +++ b/automation/playbooks/remove_node.yml @@ -89,7 +89,7 @@ default_postgresql_cluster_name: "{{ 'main' if ansible_os_family == 'Debian' else 'data' }}" default_postgresql_data_dir: "\ {% if cloud_provider | default('') | length > 0 %}\ - {{ pg_data_mount_path | default('/pgdata') }}/{{ default_postgresql_version }}/{{ default_postgresql_cluster_name }}\ + {{ postgresql_data_dir_mount_path | default('/pgdata') }}/{{ default_postgresql_version }}/{{ default_postgresql_cluster_name }}\ {% else %}\ {{ default_postgresql_home_dir }}/{{ default_postgresql_version }}/{{ default_postgresql_cluster_name }}\ {% endif %}" diff --git a/automation/requirements.txt b/automation/requirements.txt index 34146e460f..d3a34ad020 100644 --- a/automation/requirements.txt +++ b/automation/requirements.txt @@ -1,5 +1,5 @@ -ansible==12.1.0 -boto3==1.40.61 +ansible==12.2.0 +boto3==1.40.74 dopy==0.3.7 -google-auth==2.42.0 -hcloud==2.9.0 +google-auth==2.43.0 +hcloud==2.11.1 diff --git a/automation/requirements.yml b/automation/requirements.yml index dffdfbf2fe..e155e1b06a 100644 --- a/automation/requirements.yml +++ b/automation/requirements.yml @@ -1,24 +1,24 @@ --- collections: - name: amazon.aws - version: ">=10.1.1" + version: ">=10.1.2" - name: community.aws version: ">=10.0.0" - name: google.cloud - version: ">=1.8.0" + version: ">=1.10.2" - name: azure.azcollection - version: ">=3.8.0" + version: ">=3.10.1" - name: community.digitalocean version: ">=1.27.0" - name: hetzner.hcloud - version: ">=5.2.0" + version: ">=5.4.0" - name: community.postgresql - version: ">=3.14.2" + version: ">=4.1.0" - name: community.docker - version: ">=4.6.1" + version: ">=4.8.2" - name: community.general - version: ">=10.7.2" + version: ">=11.4.1" - name: ansible.posix - version: ">=1.6.2" + version: ">=2.1.0" - name: ansible.utils - version: ">=5.1.2" + version: ">=6.0.0" diff --git a/automation/roles/authorized_keys/tasks/main.yml b/automation/roles/authorized_keys/tasks/main.yml index 78e49bfc01..1f58a1661c 100644 --- a/automation/roles/authorized_keys/tasks/main.yml +++ b/automation/roles/authorized_keys/tasks/main.yml @@ -9,19 +9,17 @@ - name: "Add public keys to ~{{ system_user.stdout | default('') }}/.ssh/authorized_keys" ansible.posix.authorized_key: user: "{{ system_user.stdout }}" - key: "{{ item }}" + key: "{{ item | replace(\"'\", '') | replace('\"', '') | trim }}" state: present - loop: '{{ ssh_public_keys_list | map(''replace'', ''"'', '''') | map(''replace'', "''", "") | list }}' - vars: - ssh_public_keys_list: >- - {{ - (ssh_public_keys - | replace('\n', ',') - | split(',') - | map('trim') - | list) - if ssh_public_keys is string else ssh_public_keys - }} + loop: >- + {{ + (ssh_public_keys + | replace('\n', ',') + | split(',') + | reject('equalto', '') + | list) + if ssh_public_keys is string else ssh_public_keys + }} when: - ssh_public_keys is defined - ssh_public_keys | length > 0 diff --git a/automation/roles/cloud_resources/defaults/main.yml b/automation/roles/cloud_resources/defaults/main.yml index e918e31c5c..622218d78f 100644 --- a/automation/roles/cloud_resources/defaults/main.yml +++ b/automation/roles/cloud_resources/defaults/main.yml @@ -62,12 +62,14 @@ azure_blob_storage_absent: false # Allow to delete Azure Blob Storage when delet digital_ocean_spaces_create: true # if 'cloud_provider=digitalocean' digital_ocean_spaces_name: "{{ patroni_cluster_name }}-backup" # Name of the Spaces Object Storage (S3 bucket). -digital_ocean_spaces_region: "nyc3" # The region to create the Space in. +digital_ocean_spaces_region: "{{ (server_location in ['nyc1', 'nyc2']) | ternary('nyc3', server_location) }}" # The region to create the Space in. +digital_ocean_spaces_access_key: "" # (required) Spaces Object Storage ACCESS KEY +digital_ocean_spaces_secret_key: "" # (required) Spaces Object Storage SECRET KEY digital_ocean_spaces_absent: false # Allow to delete Spaces Object Storage when deleting a cluster servers using the 'state=absent' variable. hetzner_object_storage_create: true # if 'cloud_provider=hetzner' hetzner_object_storage_name: "{{ patroni_cluster_name }}-backup" # Name of the Object Storage (S3 bucket). -hetzner_object_storage_region: "{{ server_location }}" # The region where the Object Storage (S3 bucket) will be created. +hetzner_object_storage_region: "{{ (server_location in ['hel1', 'fsn1', 'nbg1']) | ternary(server_location, 'nbg1') }}" # The region where the Object Storage (S3 bucket) will be created. hetzner_object_storage_endpoint: "https://{{ hetzner_object_storage_region }}.your-objectstorage.com" hetzner_object_storage_access_key: "" # (required) Object Storage ACCESS KEY hetzner_object_storage_secret_key: "" # (required) Object Storage SECRET KEY diff --git a/automation/roles/cloud_resources/tasks/aws.yml b/automation/roles/cloud_resources/tasks/aws.yml index c5341ede9e..81550710e2 100644 --- a/automation/roles/cloud_resources/tasks/aws.yml +++ b/automation/roles/cloud_resources/tasks/aws.yml @@ -351,6 +351,25 @@ register: ec2_spot_request_result when: item.instances[0] | default('') | length < 1 + - name: "AWS: Wait for EC2 Spot instance to be created" + amazon.aws.ec2_instance: + access_key: "{{ lookup('ansible.builtin.env', 'AWS_ACCESS_KEY_ID') }}" + secret_key: "{{ lookup('ansible.builtin.env', 'AWS_SECRET_ACCESS_KEY') }}" + region: "{{ server_location }}" + filters: + spot-instance-request-id: "{{ item.spot_request.spot_instance_request_id }}" + loop: "{{ ec2_spot_request_result.results }}" + loop_control: + index_var: idx + label: "{{ server_name | lower }}{{ '%02d' % (idx + 1) }}" + register: ec2_spot_instance_wait_result + until: + - ec2_spot_instance_wait_result.instances[0][ip_address_type] is defined + - ec2_spot_instance_wait_result.instances[0][ip_address_type] | length > 0 + retries: 12 + delay: 10 + when: item.spot_request.spot_instance_request_id is defined + - name: "AWS: Rename the EC2 Spot instance" amazon.aws.ec2_instance: access_key: "{{ lookup('ansible.builtin.env', 'AWS_ACCESS_KEY_ID') }}" @@ -364,9 +383,7 @@ index_var: idx label: "{{ server_name | lower }}{{ '%02d' % (idx + 1) }}" register: ec2_spot_instance_result - until: - - ec2_spot_instance_result.instances[0][ip_address_type] is defined - - ec2_spot_instance_result.instances[0][ip_address_type] | length > 0 + until: ec2_spot_instance_result is success retries: 3 delay: 10 when: item.spot_request.spot_instance_request_id is defined diff --git a/automation/roles/cloud_resources/tasks/digitalocean.yml b/automation/roles/cloud_resources/tasks/digitalocean.yml index f6f50502a0..8f8e0a4394 100644 --- a/automation/roles/cloud_resources/tasks/digitalocean.yml +++ b/automation/roles/cloud_resources/tasks/digitalocean.yml @@ -143,7 +143,7 @@ ansible.builtin.set_fact: default_ip_range: >- {{ - vpc_info.data + vpc_info.get('data', []) | selectattr('region', 'equalto', server_location) | selectattr('default', 'equalto', true) | map(attribute='ip_range') @@ -151,7 +151,7 @@ }} when: - server_network | length < 1 - - vpc_info.data | selectattr('region', 'equalto', server_location) | selectattr('default', 'equalto', true) | list | length > 0 + - vpc_info.get('data', []) | selectattr('region', 'equalto', server_location) | selectattr('default', 'equalto', true) | list | length > 0 # if server_network is not specified and there is no default VPC, create a network - name: "DigitalOcean: Create a VPC '{{ digital_ocean_vpc_name | default('network-' + server_location | default('')) }}'" @@ -163,18 +163,22 @@ register: digital_ocean_vpc when: - server_network | length < 1 - - vpc_info.data | selectattr('region', 'equalto', server_location) | selectattr('default', 'equalto', true) | list | length == 0 + - vpc_info.get('data', []) | selectattr('region', 'equalto', server_location) | selectattr('default', 'equalto', true) | list | length == 0 - name: "Set variable: server_network" ansible.builtin.set_fact: server_network: "{{ digital_ocean_vpc_name | default('network-' + server_location) }}" - when: digital_ocean_vpc is changed + when: + - digital_ocean_vpc is defined + - digital_ocean_vpc is changed - name: "DigitalOcean: Gather information about VPC" community.digitalocean.digital_ocean_vpc_info: oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" register: vpc_info - when: digital_ocean_vpc is changed + when: + - digital_ocean_vpc is defined + - digital_ocean_vpc is changed # if server_network is specified - name: "Fail if no VPC found in the specified region" @@ -182,13 +186,18 @@ msg: "No VPC found with name '{{ server_network }}' in region '{{ server_location }}'" when: - server_network | length > 0 - - vpc_info.data | selectattr('region', 'equalto', server_location) | selectattr('name', 'equalto', server_network) | list | length == 0 + - (vpc_info.get('data', []) + | selectattr('region', 'equalto', server_location) + | selectattr('name', 'equalto', server_network) + | list + | length + ) == 0 - name: Extract ip_range from VPC "{{ server_network | default('') }}" ansible.builtin.set_fact: vpc_ip_range: >- {{ - vpc_info.data + vpc_info.get('data', []) | selectattr('region', 'equalto', server_location) | selectattr('name', 'equalto', server_network) | map(attribute='ip_range') @@ -200,7 +209,7 @@ ansible.builtin.set_fact: vpc_id: >- {{ - vpc_info.data + vpc_info.get('data', []) | selectattr('region', 'equalto', server_location) | selectattr('name', 'equalto', server_network) | map(attribute='id') @@ -620,9 +629,12 @@ oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" name: "{{ digital_ocean_spaces_name }}" region: "{{ digital_ocean_spaces_region }}" - aws_access_key_id: "{{ AWS_ACCESS_KEY_ID }}" - aws_secret_access_key: "{{ AWS_SECRET_ACCESS_KEY }}" + aws_access_key_id: "{{ digital_ocean_spaces_access_key | default(AWS_ACCESS_KEY_ID | default(default_access_key), true) }}" + aws_secret_access_key: "{{ digital_ocean_spaces_secret_key | default(AWS_SECRET_ACCESS_KEY | default(default_secret_key), true) }}" state: present + vars: + default_access_key: "{{ pgbackrest_s3_key | default(wal_g_aws_access_key_id | default('')) }}" + default_secret_key: "{{ pgbackrest_s3_key_secret | default(wal_g_aws_secret_access_key | default('')) }}" when: - (pgbackrest_install | bool or wal_g_install | bool) - digital_ocean_spaces_create | bool diff --git a/automation/roles/cloud_resources/tasks/hetzner.yml b/automation/roles/cloud_resources/tasks/hetzner.yml index a06312fc7b..e0c65b1476 100644 --- a/automation/roles/cloud_resources/tasks/hetzner.yml +++ b/automation/roles/cloud_resources/tasks/hetzner.yml @@ -448,8 +448,8 @@ amazon.aws.s3_bucket: endpoint_url: "{{ hetzner_object_storage_endpoint }}" ceph: true - aws_access_key: "{{ hetzner_object_storage_access_key }}" - aws_secret_key: "{{ hetzner_object_storage_secret_key }}" + aws_access_key: "{{ hetzner_object_storage_access_key | default(default_access_key, true) }}" + aws_secret_key: "{{ hetzner_object_storage_secret_key | default(default_secret_key, true) }}" name: "{{ hetzner_object_storage_name }}" region: "{{ hetzner_object_storage_region }}" requester_pays: false @@ -457,11 +457,12 @@ register: s3_bucket_result failed_when: s3_bucket_result.failed and not "GetBucketRequestPayment" in s3_bucket_result.msg # TODO: https://github.com/ansible-collections/amazon.aws/issues/2447 + vars: + default_access_key: "{{ pgbackrest_s3_key | default(wal_g_aws_access_key_id | default('')) }}" + default_secret_key: "{{ pgbackrest_s3_key_secret | default(wal_g_aws_secret_access_key | default('')) }}" when: - (pgbackrest_install | bool or wal_g_install | bool) - hetzner_object_storage_create | bool - - hetzner_object_storage_access_key | length > 0 - - hetzner_object_storage_secret_key | length > 0 # Server and volume - name: "Hetzner Cloud: Gather information about servers" diff --git a/automation/roles/cloud_resources/tasks/inventory.yml b/automation/roles/cloud_resources/tasks/inventory.yml index d0188179df..537ef7e62f 100644 --- a/automation/roles/cloud_resources/tasks/inventory.yml +++ b/automation/roles/cloud_resources/tasks/inventory.yml @@ -78,6 +78,7 @@ ansible.builtin.add_host: name: "{{ item.private_ip }}" group: consul_instances + consul_node_role: "{{ 'server' if not dcs_exists | default(false) else 'client' }}" ansible_ssh_host: "{{ item[server_public_ip | bool | ternary('public_ip', 'private_ip')] }}" ansible_ssh_private_key_file: "{{ ssh_private_key_file | default(None) }}" new_node: "{{ item.new_node | default(omit) }}" diff --git a/automation/roles/common/defaults/main.yml b/automation/roles/common/defaults/main.yml index 4cc5731fd5..be744a01dd 100644 --- a/automation/roles/common/defaults/main.yml +++ b/automation/roles/common/defaults/main.yml @@ -156,13 +156,14 @@ consul_config_path: "/etc/consul" consul_configd_path: "{{ consul_config_path }}/conf.d" consul_data_path: "/var/lib/consul" consul_domain: "consul" # Consul domain name -consul_datacenter: "dc1" # Datacenter label (can be specified for each host in the inventory) +consul_datacenter: "{{ server_location | default('dc1') }}" # Datacenter label (can be specified for each host in the inventory) consul_disable_update_check: true # Disables automatic checking for security bulletins and new version releases consul_enable_script_checks: true # This controls whether health checks that execute scripts are enabled on this agent consul_enable_local_script_checks: true # Enable them when they are defined in the local configuration files consul_ui: false # Enable the consul UI? consul_syslog_enable: true # Enable logging to syslog consul_client_address: "127.0.0.1" # Client address. Affects DNS, HTTP, HTTPS, and gRPC client interfaces. +consul_bootstrap_expect: "{{ true if consul_node_role == 'server' else false }}" consul_on_dedicated_nodes: "{{ groups['consul_instances'] | difference(groups['postgres_cluster']) | length > 0 }}" # 'true' or 'false' # TLS # Enables TLS encryption with a self-signed certificate if 'tls_cert_generate' is true. @@ -259,7 +260,7 @@ postgresql_home_dir: "{{ '/var/lib/postgresql' if ansible_os_family == 'Debian' # You can specify custom data dir path. Example: "/pgdata/{{ postgresql_version }}/main" postgresql_data_dir: "\ {% if cloud_provider | default('') | length > 0 %}\ - {{ pg_data_mount_path | default('/pgdata') }}/{{ postgresql_version }}/{{ postgresql_cluster_name }}\ + {{ postgresql_data_dir_mount_path | default('/pgdata') }}/{{ postgresql_version }}/{{ postgresql_cluster_name }}\ {% else %}\ {{ postgresql_home_dir }}/{{ postgresql_version }}/{{ postgresql_cluster_name }}\ {% endif %}" @@ -649,11 +650,11 @@ wal_g_version: 3.0.7 wal_g_installation_method: "binary" # or "src" to build from source code wal_g_path: "/usr/local/bin/wal-g --config {{ postgresql_home_dir }}/.walg.json" wal_g_json: # config https://github.com/wal-g/wal-g#configuration - - { option: "AWS_ACCESS_KEY_ID", value: "{{ AWS_ACCESS_KEY_ID | default('') }}" } # define values or pass via --extra-vars - - { option: "AWS_SECRET_ACCESS_KEY", value: "{{ AWS_SECRET_ACCESS_KEY | default('') }}" } # define values or pass via --extra-vars - - { option: "WALG_S3_PREFIX", value: "{{ WALG_S3_PREFIX | default('s3://' + patroni_cluster_name) }}" } # define values or pass via --extra-vars - - { option: "WALG_COMPRESSION_METHOD", value: "{{ WALG_COMPRESSION_METHOD | default('brotli') }}" } # or "lz4", "lzma", "zstd" - - { option: "WALG_DELTA_MAX_STEPS", value: "{{ WALG_DELTA_MAX_STEPS | default('6') }}" } # determines how many delta backups can be between full backups + - { option: "AWS_ACCESS_KEY_ID", value: "{{ wal_g_aws_access_key_id | default('') }}" } # define values or pass via --extra-vars + - { option: "AWS_SECRET_ACCESS_KEY", value: "{{ wal_g_aws_secret_access_key | default('') }}" } # define values or pass via --extra-vars + - { option: "WALG_S3_PREFIX", value: "{{ wal_g_s3_prefix | default('s3://' + patroni_cluster_name) }}" } # define values or pass via --extra-vars + - { option: "WALG_COMPRESSION_METHOD", value: "{{ wal_g_compression_method | default('brotli') }}" } # or "lz4", "lzma", "zstd" + - { option: "WALG_DELTA_MAX_STEPS", value: "{{ wal_g_delta_max_steps | default('6') }}" } # determines how many delta backups can be between full backups - { option: "WALG_PREFETCH_DIR", value: "{{ wal_g_prefetch_dir_path }}" } # prevent pg_rewind failures by setting non-default prefetch directory - { option: "PGDATA", value: "{{ postgresql_data_dir }}" } - { option: "PGHOST", value: "{{ postgresql_unix_socket_dir }}" } @@ -685,14 +686,14 @@ wal_g_delete_command: - " {{ patroni_restapi_protocol }}://{{ patroni_restapi_connect_addr | default(patroni_bind_address | default(bind_address, true), true) }}" - ":{{ patroni_restapi_port }}" - " | grep 200" - - " && {{ wal_g_path }} delete retain FULL {{ WAL_G_RETENTION_FULL | default (4) }} --confirm > {{ postgresql_log_dir }}/walg_delete.log 2>&1" + - " && {{ wal_g_path }} delete retain FULL {{ wal_g_retention_full | default (4) }} --confirm > {{ postgresql_log_dir }}/walg_delete.log 2>&1" wal_g_cron_jobs: - name: "WAL-G: Create daily backup" user: "postgres" file: /etc/cron.d/walg minute: "00" - hour: "{{ WALG_BACKUP_HOUR | default('3') }}" + hour: "{{ wal_g_backup_hour | default('3') }}" day: "*" month: "*" weekday: "*" @@ -779,7 +780,7 @@ pgbackrest_cron_jobs: file: "/etc/cron.d/pgbackrest-{{ patroni_cluster_name }}" user: "postgres" minute: "00" - hour: "{{ PGBACKREST_BACKUP_HOUR | default('3') }}" + hour: "{{ pgbackrest_backup_hour | default('3') }}" day: "*" month: "*" weekday: "0" diff --git a/automation/roles/consul/defaults/main.yml b/automation/roles/consul/defaults/main.yml index a8a35e3244..1c3852f83a 100644 --- a/automation/roles/consul/defaults/main.yml +++ b/automation/roles/consul/defaults/main.yml @@ -83,7 +83,7 @@ consul_alt_domain: "{{ lookup('env', 'CONSUL_ALT_DOMAIN') | default('', true) }} consul_node_meta: {} consul_node_role: "{{ lookup('env', 'CONSUL_NODE_ROLE') | default('client', true) }}" # consul_recursors: "{{ lookup('env', 'CONSUL_RECURSORS') | default('[]', true) }}" -consul_bootstrap_expect: "{{ lookup('env', 'CONSUL_BOOTSTRAP_EXPECT') | default(false, true) }}" +# consul_bootstrap_expect: "{{ lookup('env', 'CONSUL_BOOTSTRAP_EXPECT') | default(false, true) }}" consul_bootstrap_expect_value: "{{ _consul_lan_servercount | int }}" # consul_ui: "{{ lookup('env', 'CONSUL_UI') | default(true, true) }}" consul_ui_legacy: "{{ lookup('env', 'CONSUL_UI_LEGACY') | default(false, false) }}" diff --git a/automation/roles/mount/README.md b/automation/roles/mount/README.md index 4c112b774a..055c0617e3 100644 --- a/automation/roles/mount/README.md +++ b/automation/roles/mount/README.md @@ -14,8 +14,8 @@ This role configures filesystems and mount points: | mount[].fstype | "ext4" | Filesystem type (e.q., ext4, xfs). Use "zfs" to create a zpool and mount it. | | mount[].opts | "defaults,noatime" | Mount options (not applicable to zfs creation). | | mount[].state | "mounted" | Desired state (mounted, present, absent, etc.). | -| pg_data_mount_path | "/pgdata" | Default path used when auto-provisioning or for the ZFS mountpoint. | -| pg_data_mount_fstype | "ext4" | Filesystem type to create when auto-provisioning the first disk. Set to "zfs" to create a zpool. | +| postgresql_data_dir_mount_path | "/pgdata" | Default path used when auto-provisioning or for the ZFS mountpoint. | +| postgresql_data_dir_mount_fstype | "ext4" | Filesystem type to create when auto-provisioning the first disk. Set to "zfs" to create a zpool. | Notes: - The role relies on lsblk and jq for disk detection; ensure jq is available (it is installed by the common role by default). diff --git a/automation/roles/mount/tasks/main.yml b/automation/roles/mount/tasks/main.yml index 5ec1ab5227..60a45cda70 100644 --- a/automation/roles/mount/tasks/main.yml +++ b/automation/roles/mount/tasks/main.yml @@ -26,14 +26,14 @@ when: lsblk_disk.stdout is defined and lsblk_disk.stdout | length < 1 # Filesystem - - name: Create "{{ pg_data_mount_fstype | default('ext4') }}" filesystem on the disk "/dev/{{ lsblk_disk.stdout | default('') }}" + - name: Create "{{ postgresql_data_dir_mount_fstype | default('ext4') }}" filesystem on the disk "/dev/{{ lsblk_disk.stdout | default('') }}" community.general.filesystem: dev: "/dev/{{ lsblk_disk.stdout }}" - fstype: "{{ pg_data_mount_fstype | default('ext4') }}" + fstype: "{{ postgresql_data_dir_mount_fstype | default('ext4') }}" when: - (lsblk_disk.stdout is defined and lsblk_disk.stdout | length > 0) - - ((pg_data_mount_fstype is defined and pg_data_mount_fstype != 'zfs') or - (pg_data_mount_fstype is not defined and mount[0].fstype != 'zfs')) + - ((postgresql_data_dir_mount_fstype is defined and postgresql_data_dir_mount_fstype != 'zfs') or + (postgresql_data_dir_mount_fstype is not defined and mount[0].fstype != 'zfs')) # UUID - name: Get UUID of the disk "/dev/{{ lsblk_disk.stdout | default('') }}" @@ -46,15 +46,15 @@ changed_when: false when: - (lsblk_disk.stdout is defined and lsblk_disk.stdout | length > 0) - - ((pg_data_mount_fstype is defined and pg_data_mount_fstype != 'zfs') or - (pg_data_mount_fstype is not defined and mount[0].fstype != 'zfs')) + - ((postgresql_data_dir_mount_fstype is defined and postgresql_data_dir_mount_fstype != 'zfs') or + (postgresql_data_dir_mount_fstype is not defined and mount[0].fstype != 'zfs')) - name: "Set mount variables" ansible.builtin.set_fact: mount: - src: "UUID={{ lsblk_uuid.stdout }}" - path: "{{ pg_data_mount_path | default('/pgdata', true) }}" - fstype: "{{ pg_data_mount_fstype | default('ext4', true) }}" + path: "{{ postgresql_data_dir_mount_path | default('/pgdata', true) }}" + fstype: "{{ postgresql_data_dir_mount_fstype | default('ext4', true) }}" when: lsblk_uuid.stdout is defined # Mount @@ -62,14 +62,14 @@ ansible.posix.mount: path: "{{ item.path }}" src: "{{ item.src }}" - fstype: "{{ item.fstype | default(pg_data_mount_fstype | default('ext4', true), true) }}" + fstype: "{{ item.fstype | default(postgresql_data_dir_mount_fstype | default('ext4', true), true) }}" opts: "{{ item.opts | default('defaults,noatime') }}" state: "{{ item.state | default('mounted') }}" loop: "{{ mount }}" when: - (item.src | length > 0 and item.path | length > 0) - - ((pg_data_mount_fstype is defined and pg_data_mount_fstype != 'zfs') or - (pg_data_mount_fstype is not defined and item.fstype != 'zfs')) + - ((postgresql_data_dir_mount_fstype is defined and postgresql_data_dir_mount_fstype != 'zfs') or + (postgresql_data_dir_mount_fstype is not defined and item.fstype != 'zfs')) # ZFS Pool (if fstype is 'zfs') - block: @@ -150,10 +150,10 @@ -O atime=off -O recordsize=128k -O logbias=throughput - -m {{ pg_data_mount_path | default(mount[0].path | default('/pgdata', true), true) }} + -m {{ postgresql_data_dir_mount_path | default(mount[0].path | default('/pgdata', true), true) }} pgdata {{ mount[0].src | default("/dev/" + lsblk_disk.stdout, true) }} when: - (mount[0].src | length > 0 or lsblk_disk.stdout | default('') | length > 0) - - ((pg_data_mount_fstype is defined and pg_data_mount_fstype == 'zfs') or - (pg_data_mount_fstype is not defined and mount[0].fstype == 'zfs')) + - ((postgresql_data_dir_mount_fstype is defined and postgresql_data_dir_mount_fstype == 'zfs') or + (postgresql_data_dir_mount_fstype is not defined and mount[0].fstype == 'zfs')) tags: mount, zpool diff --git a/automation/roles/packages/tasks/extensions_github.yml b/automation/roles/packages/tasks/extensions_github.yml index 51ff4ca742..41cd0cb146 100644 --- a/automation/roles/packages/tasks/extensions_github.yml +++ b/automation/roles/packages/tasks/extensions_github.yml @@ -80,7 +80,7 @@ when: extracted_package is defined and extracted_package.files | default([]) | length == 0 when: - github_package_url | length > 0 - - github_package_url | regex_search('\.(zip|tar\.gz)$') + - github_package_url is regex('\.(zip|tar\.gz)$') # Install - block: diff --git a/automation/roles/pgbackrest/tasks/auto_conf.yml b/automation/roles/pgbackrest/tasks/auto_conf.yml index 4a8e58588c..2aec1ca510 100644 --- a/automation/roles/pgbackrest/tasks/auto_conf.yml +++ b/automation/roles/pgbackrest/tasks/auto_conf.yml @@ -8,18 +8,18 @@ - { option: "log-level-file", value: "detail" } - { option: "log-path", value: "/var/log/pgbackrest" } - { option: "repo1-type", value: "s3" } - - { option: "repo1-path", value: "{{ PGBACKREST_REPO_PATH | default('/pgbackrest') }}" } - - { option: "repo1-s3-key", value: "{{ PGBACKREST_S3_KEY | default(lookup('ansible.builtin.env', 'AWS_ACCESS_KEY_ID')) }}" } - - { option: "repo1-s3-key-secret", value: "{{ PGBACKREST_S3_KEY_SECRET | default(lookup('ansible.builtin.env', 'AWS_SECRET_ACCESS_KEY')) }}" } - - { option: "repo1-s3-bucket", value: "{{ PGBACKREST_S3_BUCKET | default(aws_s3_bucket_name | default(patroni_cluster_name + '-backup')) }}" } + - { option: "repo1-path", value: "{{ pgbackrest_repo_path | default('/pgbackrest') }}" } + - { option: "repo1-s3-key", value: "{{ pgbackrest_s3_key | default(lookup('ansible.builtin.env', 'AWS_ACCESS_KEY_ID')) }}" } + - { option: "repo1-s3-key-secret", value: "{{ pgbackrest_s3_key_secret | default(lookup('ansible.builtin.env', 'AWS_SECRET_ACCESS_KEY')) }}" } + - { option: "repo1-s3-bucket", value: "{{ pgbackrest_s3_bucket | default(aws_s3_bucket_name | default(patroni_cluster_name + '-backup')) }}" } - { option: "repo1-s3-endpoint", - value: "{{ PGBACKREST_S3_ENDPOINT | default('s3.' + (aws_s3_bucket_region | default(server_location)) + '.amazonaws.com') }}", + value: "{{ pgbackrest_s3_endpoint | default('s3.' + (aws_s3_bucket_region | default(server_location)) + '.amazonaws.com') }}", } - - { option: "repo1-s3-region", value: "{{ PGBACKREST_S3_REGION | default(aws_s3_bucket_region | default(server_location)) }}" } - - { option: "repo1-retention-full", value: "{{ PGBACKREST_RETENTION_FULL | default('4') }}" } - - { option: "repo1-retention-archive", value: "{{ PGBACKREST_RETENTION_ARCHIVE | default('4') }}" } - - { option: "repo1-retention-archive-type", value: "{{ PGBACKREST_RETENTION_ARCHIVE_TYPE | default('full') }}" } + - { option: "repo1-s3-region", value: "{{ pgbackrest_s3_region | default(aws_s3_bucket_region | default(server_location)) }}" } + - { option: "repo1-retention-full", value: "{{ pgbackrest_retention_full | default('4') }}" } + - { option: "repo1-retention-archive", value: "{{ pgbackrest_retention_archive | default('4') }}" } + - { option: "repo1-retention-archive-type", value: "{{ pgbackrest_retention_archive_type | default('full') }}" } - { option: "repo1-bundle", value: "y" } - { option: "repo1-block", value: "y" } - { option: "start-fast", value: "y" } @@ -29,7 +29,7 @@ - { option: "archive-async", value: "y" } - { option: "archive-get-queue-max", value: "1GiB" } - { option: "spool-path", value: "/var/spool/pgbackrest" } - - { option: "process-max", value: "{{ PGBACKREST_PROCESS_MAX | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } + - { option: "process-max", value: "{{ pgbackrest_process_max | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } - { option: "backup-standby", value: "{{ 'y' if groups['postgres_cluster'] | length > 1 else 'n' }}" } stanza: - { option: "log-level-console", value: "info" } @@ -49,12 +49,12 @@ - { option: "log-level-file", value: "detail" } - { option: "log-path", value: "/var/log/pgbackrest" } - { option: "repo1-type", value: "gcs" } - - { option: "repo1-path", value: "{{ PGBACKREST_REPO_PATH | default('/pgbackrest') }}" } - - { option: "repo1-gcs-key", value: "{{ PGBACKREST_GCS_KEY | default(postgresql_home_dir + '/gcs-key.json') }}" } - - { option: "repo1-gcs-bucket", value: "{{ PGBACKREST_GCS_BUCKET | default(gcp_bucket_name | default(patroni_cluster_name + '-backup')) }}" } - - { option: "repo1-retention-full", value: "{{ PGBACKREST_RETENTION_FULL | default('4') }}" } - - { option: "repo1-retention-archive", value: "{{ PGBACKREST_RETENTION_ARCHIVE | default('4') }}" } - - { option: "repo1-retention-archive-type", value: "{{ PGBACKREST_RETENTION_ARCHIVE_TYPE | default('full') }}" } + - { option: "repo1-path", value: "{{ pgbackrest_repo_path | default('/pgbackrest') }}" } + - { option: "repo1-gcs-key", value: "{{ pgbackrest_gcs_key | default(postgresql_home_dir + '/gcs-key.json') }}" } + - { option: "repo1-gcs-bucket", value: "{{ pgbackrest_gcs_bucket | default(gcp_bucket_name | default(patroni_cluster_name + '-backup')) }}" } + - { option: "repo1-retention-full", value: "{{ pgbackrest_retention_full | default('4') }}" } + - { option: "repo1-retention-archive", value: "{{ pgbackrest_retention_archive | default('4') }}" } + - { option: "repo1-retention-archive-type", value: "{{ pgbackrest_retention_archive_type | default('full') }}" } - { option: "repo1-bundle", value: "y" } - { option: "repo1-block", value: "y" } - { option: "start-fast", value: "y" } @@ -64,7 +64,7 @@ - { option: "archive-async", value: "y" } - { option: "archive-get-queue-max", value: "1GiB" } - { option: "spool-path", value: "/var/spool/pgbackrest" } - - { option: "process-max", value: "{{ PGBACKREST_PROCESS_MAX | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } + - { option: "process-max", value: "{{ pgbackrest_process_max | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } - { option: "backup-standby", value: "{{ 'y' if groups['postgres_cluster'] | length > 1 else 'n' }}" } stanza: - { option: "log-level-console", value: "info" } @@ -81,10 +81,10 @@ run_once: true # noqa run-once no_log: true # do not output GCP service account contents to the ansible log - - name: "Copy GCP service account contents to {{ PGBACKREST_GCS_KEY | default((postgresql_home_dir | default('')) ~ '/gcs-key.json') }}" + - name: "Copy GCP service account contents to {{ pgbackrest_gcs_key | default((postgresql_home_dir | default('')) ~ '/gcs-key.json') }}" ansible.builtin.copy: content: "{{ gcp_service_account_contents }}" - dest: "{{ PGBACKREST_GCS_KEY | default(postgresql_home_dir + '/gcs-key.json') }}" + dest: "{{ pgbackrest_gcs_key | default(postgresql_home_dir + '/gcs-key.json') }}" mode: "0600" owner: "postgres" group: "postgres" @@ -92,10 +92,10 @@ when: gcs_key_file is not defined # if 'gcs_key_file' is defined, copy this GCS key file. - - name: "Copy GCS key file to {{ PGBACKREST_GCS_KEY | default((postgresql_home_dir | default('')) ~ '/gcs-key.json') }}" + - name: "Copy GCS key file to {{ pgbackrest_gcs_key | default((postgresql_home_dir | default('')) ~ '/gcs-key.json') }}" ansible.builtin.copy: src: "{{ gcs_key_file }}" - dest: "{{ PGBACKREST_GCS_KEY | default(postgresql_home_dir + '/gcs-key.json') }}" + dest: "{{ pgbackrest_gcs_key | default(postgresql_home_dir + '/gcs-key.json') }}" mode: "0600" owner: "postgres" group: "postgres" @@ -111,20 +111,20 @@ - { option: "log-level-file", value: "detail" } - { option: "log-path", value: "/var/log/pgbackrest" } - { option: "repo1-type", value: "azure" } - - { option: "repo1-path", value: "{{ PGBACKREST_REPO_PATH | default('/pgbackrest') }}" } - - { option: "repo1-azure-key", value: "{{ PGBACKREST_AZURE_KEY | default(hostvars['localhost']['azure_storage_account_key'] | default('')) }}" } - - { option: "repo1-azure-key-type", value: "{{ PGBACKREST_AZURE_KEY_TYPE | default('shared') }}" } + - { option: "repo1-path", value: "{{ pgbackrest_repo_path | default('/pgbackrest') }}" } + - { option: "repo1-azure-key", value: "{{ pgbackrest_azure_key | default(hostvars['localhost']['azure_storage_account_key'] | default('')) }}" } + - { option: "repo1-azure-key-type", value: "{{ pgbackrest_azure_key_type | default('shared') }}" } - { option: "repo1-azure-account", - value: "{{ PGBACKREST_AZURE_ACCOUNT | default(azure_blob_storage_account_name | default(patroni_cluster_name | lower | replace('-', '') | truncate(24, true, ''))) }}", + value: "{{ pgbackrest_azure_account | default(azure_blob_storage_account_name | default(patroni_cluster_name | lower | replace('-', '') | truncate(24, true, ''))) }}", } - { option: "repo1-azure-container", - value: "{{ PGBACKREST_AZURE_CONTAINER | default(azure_blob_storage_name | default(patroni_cluster_name + '-backup')) }}", + value: "{{ pgbackrest_azure_container | default(azure_blob_storage_name | default(patroni_cluster_name + '-backup')) }}", } - - { option: "repo1-retention-full", value: "{{ PGBACKREST_RETENTION_FULL | default('4') }}" } - - { option: "repo1-retention-archive", value: "{{ PGBACKREST_RETENTION_ARCHIVE | default('4') }}" } - - { option: "repo1-retention-archive-type", value: "{{ PGBACKREST_RETENTION_ARCHIVE_TYPE | default('full') }}" } + - { option: "repo1-retention-full", value: "{{ pgbackrest_retention_full | default('4') }}" } + - { option: "repo1-retention-archive", value: "{{ pgbackrest_retention_archive | default('4') }}" } + - { option: "repo1-retention-archive-type", value: "{{ pgbackrest_retention_archive_type | default('full') }}" } - { option: "repo1-bundle", value: "y" } - { option: "repo1-block", value: "y" } - { option: "start-fast", value: "y" } @@ -134,7 +134,7 @@ - { option: "archive-async", value: "y" } - { option: "archive-get-queue-max", value: "1GiB" } - { option: "spool-path", value: "/var/spool/pgbackrest" } - - { option: "process-max", value: "{{ PGBACKREST_PROCESS_MAX | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } + - { option: "process-max", value: "{{ pgbackrest_process_max | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } - { option: "backup-standby", value: "{{ 'y' if groups['postgres_cluster'] | length > 1 else 'n' }}" } stanza: - { option: "log-level-console", value: "info" } @@ -152,19 +152,19 @@ - { option: "log-level-file", value: "detail" } - { option: "log-path", value: "/var/log/pgbackrest" } - { option: "repo1-type", value: "s3" } - - { option: "repo1-path", value: "{{ PGBACKREST_REPO_PATH | default('/pgbackrest') }}" } - - { option: "repo1-s3-key", value: "{{ PGBACKREST_S3_KEY | default(AWS_ACCESS_KEY_ID | default('')) }}" } - - { option: "repo1-s3-key-secret", value: "{{ PGBACKREST_S3_KEY_SECRET | default(AWS_SECRET_ACCESS_KEY | default('')) }}" } - - { option: "repo1-s3-bucket", value: "{{ PGBACKREST_S3_BUCKET | default(digital_ocean_spaces_name | default(patroni_cluster_name + '-backup')) }}" } + - { option: "repo1-path", value: "{{ pgbackrest_repo_path | default('/pgbackrest') }}" } + - { option: "repo1-s3-key", value: "{{ pgbackrest_s3_key | default(digital_ocean_spaces_access_key | default(AWS_ACCESS_KEY_ID | default(''))) }}" } + - { option: "repo1-s3-key-secret", value: "{{ pgbackrest_s3_key_secret | default(digital_ocean_spaces_secret_key | default(AWS_SECRET_ACCESS_KEY | default(''))) }}" } + - { option: "repo1-s3-bucket", value: "{{ pgbackrest_s3_bucket | default(digital_ocean_spaces_name | default(patroni_cluster_name + '-backup')) }}" } - { option: "repo1-s3-endpoint", - value: "{{ PGBACKREST_S3_ENDPOINT | default('https://' + (digital_ocean_spaces_region | default(server_location)) + '.digitaloceanspaces.com') }}", + value: "{{ pgbackrest_s3_endpoint | default('https://' + (digital_ocean_spaces_region | default(default_region)) + '.digitaloceanspaces.com') }}", } - - { option: "repo1-s3-region", value: "{{ PGBACKREST_S3_REGION | default(digital_ocean_spaces_region | default(server_location)) }}" } - - { option: "repo1-s3-uri-style", value: "{{ PGBACKREST_S3_URI_STYLE | default('path') }}" } - - { option: "repo1-retention-full", value: "{{ PGBACKREST_RETENTION_FULL | default('4') }}" } - - { option: "repo1-retention-archive", value: "{{ PGBACKREST_RETENTION_ARCHIVE | default('4') }}" } - - { option: "repo1-retention-archive-type", value: "{{ PGBACKREST_RETENTION_ARCHIVE_TYPE | default('full') }}" } + - { option: "repo1-s3-region", value: "{{ pgbackrest_s3_region | default(digital_ocean_spaces_region | default(default_region)) }}" } + - { option: "repo1-s3-uri-style", value: "{{ pgbackrest_s3_uri_style | default('path') }}" } + - { option: "repo1-retention-full", value: "{{ pgbackrest_retention_full | default('4') }}" } + - { option: "repo1-retention-archive", value: "{{ pgbackrest_retention_archive | default('4') }}" } + - { option: "repo1-retention-archive-type", value: "{{ pgbackrest_retention_archive_type | default('full') }}" } - { option: "repo1-bundle", value: "y" } - { option: "repo1-block", value: "y" } - { option: "start-fast", value: "y" } @@ -174,12 +174,14 @@ - { option: "archive-async", value: "y" } - { option: "archive-get-queue-max", value: "1GiB" } - { option: "spool-path", value: "/var/spool/pgbackrest" } - - { option: "process-max", value: "{{ PGBACKREST_PROCESS_MAX | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } + - { option: "process-max", value: "{{ pgbackrest_process_max | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } - { option: "backup-standby", value: "{{ 'y' if groups['postgres_cluster'] | length > 1 else 'n' }}" } stanza: - { option: "log-level-console", value: "info" } - { option: "recovery-option", value: "recovery_target_action=promote" } - { option: "pg1-path", value: "{{ postgresql_data_dir }}" } + vars: + default_region: "{{ (server_location in ['nyc1', 'nyc2']) | ternary('nyc3', server_location) }}" no_log: true # do not output contents to the ansible log when: cloud_provider | default('') | lower == 'digitalocean' @@ -191,19 +193,19 @@ - { option: "log-level-file", value: "detail" } - { option: "log-path", value: "/var/log/pgbackrest" } - { option: "repo1-type", value: "s3" } - - { option: "repo1-path", value: "{{ PGBACKREST_REPO_PATH | default('/pgbackrest') }}" } - - { option: "repo1-s3-key", value: "{{ PGBACKREST_S3_KEY | default(hetzner_object_storage_access_key | default('')) }}" } - - { option: "repo1-s3-key-secret", value: "{{ PGBACKREST_S3_KEY_SECRET | default(hetzner_object_storage_secret_key | default('')) }}" } - - { option: "repo1-s3-bucket", value: "{{ PGBACKREST_S3_BUCKET | default(hetzner_object_storage_name | default(patroni_cluster_name + '-backup')) }}" } + - { option: "repo1-path", value: "{{ pgbackrest_repo_path | default('/pgbackrest') }}" } + - { option: "repo1-s3-key", value: "{{ pgbackrest_s3_key | default(hetzner_object_storage_access_key | default('')) }}" } + - { option: "repo1-s3-key-secret", value: "{{ pgbackrest_s3_key_secret | default(hetzner_object_storage_secret_key | default('')) }}" } + - { option: "repo1-s3-bucket", value: "{{ pgbackrest_s3_bucket | default(hetzner_object_storage_name | default(patroni_cluster_name + '-backup')) }}" } - { option: "repo1-s3-endpoint", - value: "{{ PGBACKREST_S3_ENDPOINT | default(hetzner_object_storage_endpoint | default('https://' + (hetzner_object_storage_region | default(server_location)) + '.your-objectstorage.com')) }}", + value: "{{ pgbackrest_s3_endpoint | default(hetzner_object_storage_endpoint | default('https://' + (hetzner_object_storage_region | default(default_region)) + '.your-objectstorage.com')) }}", } - - { option: "repo1-s3-region", value: "{{ PGBACKREST_S3_REGION | default(hetzner_object_storage_region | default(server_location)) }}" } - - { option: "repo1-s3-uri-style", value: "{{ PGBACKREST_S3_URI_STYLE | default('path') }}" } - - { option: "repo1-retention-full", value: "{{ PGBACKREST_RETENTION_FULL | default('4') }}" } - - { option: "repo1-retention-archive", value: "{{ PGBACKREST_RETENTION_ARCHIVE | default('4') }}" } - - { option: "repo1-retention-archive-type", value: "{{ PGBACKREST_RETENTION_ARCHIVE_TYPE | default('full') }}" } + - { option: "repo1-s3-region", value: "{{ pgbackrest_s3_region | default(hetzner_object_storage_region | default(default_region)) }}" } + - { option: "repo1-s3-uri-style", value: "{{ pgbackrest_s3_uri_style | default('path') }}" } + - { option: "repo1-retention-full", value: "{{ pgbackrest_retention_full | default('4') }}" } + - { option: "repo1-retention-archive", value: "{{ pgbackrest_retention_archive | default('4') }}" } + - { option: "repo1-retention-archive-type", value: "{{ pgbackrest_retention_archive_type | default('full') }}" } - { option: "repo1-bundle", value: "y" } - { option: "repo1-block", value: "y" } - { option: "start-fast", value: "y" } @@ -213,12 +215,14 @@ - { option: "archive-async", value: "y" } - { option: "archive-get-queue-max", value: "1GiB" } - { option: "spool-path", value: "/var/spool/pgbackrest" } - - { option: "process-max", value: "{{ PGBACKREST_PROCESS_MAX | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } + - { option: "process-max", value: "{{ pgbackrest_process_max | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } - { option: "backup-standby", value: "{{ 'y' if groups['postgres_cluster'] | length > 1 else 'n' }}" } stanza: - { option: "log-level-console", value: "info" } - { option: "recovery-option", value: "recovery_target_action=promote" } - { option: "pg1-path", value: "{{ postgresql_data_dir }}" } + vars: + default_region: "{{ (server_location in ['hel1', 'fsn1', 'nbg1']) | ternary(server_location, 'nbg1') }}" delegate_to: localhost run_once: true # noqa run-once no_log: true # do not output contents to the ansible log diff --git a/automation/roles/pgbouncer/templates/pgbouncer.ini.j2 b/automation/roles/pgbouncer/templates/pgbouncer.ini.j2 index 95dd5b2674..9851e78607 100644 --- a/automation/roles/pgbouncer/templates/pgbouncer.ini.j2 +++ b/automation/roles/pgbouncer/templates/pgbouncer.ini.j2 @@ -1,6 +1,14 @@ [databases] {% for pool in pgbouncer_pools %} -{{ pool.name }} = host={{ postgresql_unix_socket_dir }} port={{ postgresql_port }} dbname={{ pool.dbname }} {{ pool.pool_parameters }} +{% set params = [] %} +{% if pool.pool_parameters is string %} +{% set _ = params.append(pool.pool_parameters) %} +{% elif pool.pool_parameters is mapping %} +{% for k, v in pool.pool_parameters.items() %} +{% set _ = params.append(k ~ '=' ~ v) %} +{% endfor %} +{% endif %} +{{ pool.name }} = host={{ postgresql_unix_socket_dir }} port={{ postgresql_port }} dbname={{ pool.dbname }} {{ params | join(' ') }} {% endfor %} * = host={{ postgresql_unix_socket_dir }} port={{ postgresql_port }} diff --git a/automation/roles/postgresql_users/tasks/main.yml b/automation/roles/postgresql_users/tasks/main.yml index 587bd3d1a5..2c1d379d23 100644 --- a/automation/roles/postgresql_users/tasks/main.yml +++ b/automation/roles/postgresql_users/tasks/main.yml @@ -6,7 +6,7 @@ name: "{{ item.name }}" password: "{{ item.password | default(omit) }}" encrypted: true - role_attr_flags: "{{ item.flags }}" + role_attr_flags: "{{ item.flags | default('LOGIN') }}" login_host: "127.0.0.1" login_port: "{{ postgresql_port }}" login_user: "{{ patroni_superuser_username }}" diff --git a/automation/roles/pre_checks/tasks/extensions.yml b/automation/roles/pre_checks/tasks/extensions.yml index f21326f495..0cb2d644d7 100644 --- a/automation/roles/pre_checks/tasks/extensions.yml +++ b/automation/roles/pre_checks/tasks/extensions.yml @@ -25,27 +25,27 @@ ansible.builtin.fail: msg: - "pgvectorscale is not supported on {{ ansible_distribution }} {{ ansible_distribution_release }}." - - "Supported OS: Debian (bookworm), Ubuntu (jammy, noble)." + - "Note: packages on the timescale/pgvectorscale github repository are available only for Debian-based distros." when: - enable_pgvectorscale | default(false) | bool - - not (ansible_os_family == "Debian" and ansible_distribution_release in ['bookworm', 'jammy', 'noble']) + - not ansible_os_family == "Debian" - name: ParadeDB | Checking PostgreSQL version ansible.builtin.fail: msg: - - "The current PostgreSQL version ({{ postgresql_version }}) is not supported by the ParadeDB (pg_search, pg_analytics)." + - "The current PostgreSQL version ({{ postgresql_version }}) is not supported by the ParadeDB (pg_search)." - "PostgreSQL version must be {{ paradedb_minimal_pg_version | default(14) }} or higher." when: - - (enable_paradedb | default(false) | bool) or (enable_pg_search | default(false) | bool) or (enable_pg_analytics | default(false) | bool) + - (enable_paradedb | default(false) | bool) or (enable_pg_search | default(false) | bool) - postgresql_version | string is version(paradedb_minimal_pg_version | default(14) | string, '<') - name: ParadeDB | Checking supported operating system and version ansible.builtin.fail: msg: - - "ParadeDB (pg_search, pg_analytics) is not supported on {{ ansible_distribution }} {{ ansible_distribution_release }}." - - "Supported OS: Debian (bullseye, bookworm), Ubuntu (jammy, noble) or RedHat (8, 9)." + - "ParadeDB (pg_search) is not supported on {{ ansible_distribution }} {{ ansible_distribution_release }}." + - "Note: packages on the paradedb github repository are available only for Debian 12/13, Ubuntu 22.04/24.04 or RedHat 8/9." when: - - (enable_paradedb | default(false) | bool) or (enable_pg_search | default(false) | bool) or (enable_pg_analytics | default(false) | bool) + - (enable_paradedb | default(false) | bool) or (enable_pg_search | default(false) | bool) - not ( (ansible_os_family == "Debian" and ansible_distribution_release in ['bullseye', 'bookworm', 'jammy', 'noble']) or (ansible_os_family == "RedHat" and ansible_distribution_major_version in ['8', '9']) diff --git a/automation/roles/pre_checks/tasks/pgbouncer.yml b/automation/roles/pre_checks/tasks/pgbouncer.yml index dda05c875d..5e85148e40 100644 --- a/automation/roles/pre_checks/tasks/pgbouncer.yml +++ b/automation/roles/pre_checks/tasks/pgbouncer.yml @@ -16,15 +16,23 @@ # The calculated pool size is then added to the total 'pgbouncer_pool_size'. - name: PgBouncer | Calculate pool_size ansible.builtin.set_fact: - pgbouncer_pool_size: "{{ - (pgbouncer_pool_size | default(0) | int) - + - (pool_item.pool_parameters - | regex_search('pool_size=(\\d+)', multiline=False) - | regex_replace('[^0-9]', '') - | default(pgbouncer_default_pool_size | default(0), true) - | int) - }}" + pgbouncer_pool_size: >- + {{ + (pgbouncer_pool_size | default(0) | int) + + + ( + ( + pool_item.pool_parameters.pool_size + if pool_item.pool_parameters is mapping + else ( + pool_item.pool_parameters + | regex_search('pool_size\s*=?\s*(\d+)', multiline=False) + ) + ) + | default(pgbouncer_default_pool_size | default(0), true) + | int + ) + }} loop: "{{ pgbouncer_pools | default([]) }}" loop_control: loop_var: pool_item diff --git a/automation/roles/sysctl/tasks/main.yml b/automation/roles/sysctl/tasks/main.yml index 1fc1ef9399..da4ee4c827 100644 --- a/automation/roles/sysctl/tasks/main.yml +++ b/automation/roles/sysctl/tasks/main.yml @@ -10,7 +10,7 @@ - name: Setting kernel parameters ansible.posix.sysctl: - name: "{{ item.name }}" + name: "{{ item.option | default(item.name) }}" value: "{{ item.value }}" sysctl_set: true state: present diff --git a/automation/roles/wal_g/tasks/auto_conf.yml b/automation/roles/wal_g/tasks/auto_conf.yml index 82edb0e2ce..30a14d5799 100644 --- a/automation/roles/wal_g/tasks/auto_conf.yml +++ b/automation/roles/wal_g/tasks/auto_conf.yml @@ -4,16 +4,16 @@ - name: "Set variable 'wal_g_json' for backup in AWS S3 bucket" ansible.builtin.set_fact: wal_g_json: - - { option: "AWS_ACCESS_KEY_ID", value: "{{ WALG_AWS_ACCESS_KEY_ID | default(lookup('ansible.builtin.env', 'AWS_ACCESS_KEY_ID')) }}" } - - { option: "AWS_SECRET_ACCESS_KEY", value: "{{ WALG_AWS_SECRET_ACCESS_KEY | default(lookup('ansible.builtin.env', 'AWS_SECRET_ACCESS_KEY')) }}" } - - { option: "WALG_S3_PREFIX", value: "{{ WALG_S3_PREFIX | default('s3://' + (aws_s3_bucket_name | default(patroni_cluster_name + '-backup'))) }}" } - - { option: "AWS_REGION", value: "{{ WALG_AWS_REGION | default(aws_s3_bucket_region | default(server_location)) }}" } - - { option: "WALG_COMPRESSION_METHOD", value: "{{ WALG_COMPRESSION_METHOD | default('brotli') }}" } - - { option: "WALG_DELTA_MAX_STEPS", value: "{{ WALG_DELTA_MAX_STEPS | default('6') }}" } - - { option: "WALG_DOWNLOAD_CONCURRENCY", value: "{{ WALG_DOWNLOAD_CONCURRENCY | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } + - { option: "AWS_ACCESS_KEY_ID", value: "{{ wal_g_aws_access_key_id | default(lookup('ansible.builtin.env', 'AWS_ACCESS_KEY_ID')) }}" } + - { option: "AWS_SECRET_ACCESS_KEY", value: "{{ wal_g_aws_secret_access_key | default(lookup('ansible.builtin.env', 'AWS_SECRET_ACCESS_KEY')) }}" } + - { option: "WALG_S3_PREFIX", value: "{{ wal_g_s3_prefix | default('s3://' + (aws_s3_bucket_name | default(patroni_cluster_name + '-backup'))) }}" } + - { option: "AWS_REGION", value: "{{ wal_g_aws_region | default(aws_s3_bucket_region | default(server_location)) }}" } + - { option: "WALG_COMPRESSION_METHOD", value: "{{ wal_g_compression_method | default('brotli') }}" } + - { option: "WALG_DELTA_MAX_STEPS", value: "{{ wal_g_delta_max_steps | default('6') }}" } + - { option: "WALG_DOWNLOAD_CONCURRENCY", value: "{{ wal_g_download_concurrency | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } - { option: "WALG_PREFETCH_DIR", value: "{{ wal_g_prefetch_dir_path | default(postgresql_home_dir + '/wal-g-prefetch') }}" } - - { option: "WALG_UPLOAD_CONCURRENCY", value: "{{ WALG_UPLOAD_CONCURRENCY | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } - - { option: "WALG_UPLOAD_DISK_CONCURRENCY", value: "{{ WALG_UPLOAD_DISK_CONCURRENCY | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } + - { option: "WALG_UPLOAD_CONCURRENCY", value: "{{ wal_g_upload_concurrency | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } + - { option: "WALG_UPLOAD_DISK_CONCURRENCY", value: "{{ wal_g_upload_disk_concurrency | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } - { option: "PGDATA", value: "{{ postgresql_data_dir }}" } - { option: "PGHOST", value: "{{ postgresql_unix_socket_dir | default('/var/run/postgresql') }}" } - { option: "PGPORT", value: "{{ postgresql_port | default('5432') }}" } @@ -28,14 +28,14 @@ - name: "Set variable 'wal_g_json' for backup in GCS Bucket" ansible.builtin.set_fact: wal_g_json: - - { option: "GOOGLE_APPLICATION_CREDENTIALS", value: "{{ WALG_GS_KEY | default(postgresql_home_dir + '/gcs-key.json') }}" } - - { option: "WALG_GS_PREFIX", value: "{{ WALG_GS_PREFIX | default('gs://' + (gcp_bucket_name | default(patroni_cluster_name + '-backup'))) }}" } - - { option: "WALG_COMPRESSION_METHOD", value: "{{ WALG_COMPRESSION_METHOD | default('brotli') }}" } - - { option: "WALG_DELTA_MAX_STEPS", value: "{{ WALG_DELTA_MAX_STEPS | default('6') }}" } - - { option: "WALG_DOWNLOAD_CONCURRENCY", value: "{{ WALG_DOWNLOAD_CONCURRENCY | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } + - { option: "GOOGLE_APPLICATION_CREDENTIALS", value: "{{ wal_g_gs_key | default(postgresql_home_dir + '/gcs-key.json') }}" } + - { option: "WALG_GS_PREFIX", value: "{{ wal_g_gs_prefix | default('gs://' + (gcp_bucket_name | default(patroni_cluster_name + '-backup'))) }}" } + - { option: "WALG_COMPRESSION_METHOD", value: "{{ wal_g_compression_method | default('brotli') }}" } + - { option: "WALG_DELTA_MAX_STEPS", value: "{{ wal_g_delta_max_steps | default('6') }}" } + - { option: "WALG_DOWNLOAD_CONCURRENCY", value: "{{ wal_g_download_concurrency | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } - { option: "WALG_PREFETCH_DIR", value: "{{ wal_g_prefetch_dir_path | default(postgresql_home_dir + '/wal-g-prefetch') }}" } - - { option: "WALG_UPLOAD_CONCURRENCY", value: "{{ WALG_UPLOAD_CONCURRENCY | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } - - { option: "WALG_UPLOAD_DISK_CONCURRENCY", value: "{{ WALG_UPLOAD_DISK_CONCURRENCY | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } + - { option: "WALG_UPLOAD_CONCURRENCY", value: "{{ wal_g_upload_concurrency | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } + - { option: "WALG_UPLOAD_DISK_CONCURRENCY", value: "{{ wal_g_upload_disk_concurrency | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } - { option: "PGDATA", value: "{{ postgresql_data_dir }}" } - { option: "PGHOST", value: "{{ postgresql_unix_socket_dir | default('/var/run/postgresql') }}" } - { option: "PGPORT", value: "{{ postgresql_port | default('5432') }}" } @@ -79,22 +79,22 @@ wal_g_json: - { option: "AZURE_STORAGE_ACCOUNT", - value: "{{ WALG_AZURE_STORAGE_ACCOUNT | default(azure_blob_storage_account_name | default(patroni_cluster_name | lower | replace('-', '') | truncate(24, true, ''))) }}", + value: "{{ wal_g_azure_storage_account | default(azure_blob_storage_account_name | default(patroni_cluster_name | lower | replace('-', '') | truncate(24, true, ''))) }}", } - { option: "AZURE_STORAGE_ACCESS_KEY", - value: "{{ WALG_AZURE_STORAGE_ACCESS_KEY | default(hostvars['localhost']['azure_storage_account_key'] | default('')) }}", + value: "{{ wal_g_azure_storage_access_key | default(hostvars['localhost']['azure_storage_account_key'] | default('')) }}", } - { option: "WALG_AZ_PREFIX", - value: "{{ WALG_AZ_PREFIX | default('azure://' + (azure_blob_storage_name | default(patroni_cluster_name + '-backup'))) }}", + value: "{{ wal_g_az_prefix | default('azure://' + (azure_blob_storage_name | default(patroni_cluster_name + '-backup'))) }}", } - - { option: "WALG_COMPRESSION_METHOD", value: "{{ WALG_COMPRESSION_METHOD | default('brotli') }}" } - - { option: "WALG_DELTA_MAX_STEPS", value: "{{ WALG_DELTA_MAX_STEPS | default('6') }}" } - - { option: "WALG_DOWNLOAD_CONCURRENCY", value: "{{ WALG_DOWNLOAD_CONCURRENCY | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } + - { option: "WALG_COMPRESSION_METHOD", value: "{{ wal_g_compression_method | default('brotli') }}" } + - { option: "WALG_DELTA_MAX_STEPS", value: "{{ wal_g_delta_max_steps | default('6') }}" } + - { option: "WALG_DOWNLOAD_CONCURRENCY", value: "{{ wal_g_download_concurrency | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } - { option: "WALG_PREFETCH_DIR", value: "{{ wal_g_prefetch_dir_path | default(postgresql_home_dir + '/wal-g-prefetch') }}" } - - { option: "WALG_UPLOAD_CONCURRENCY", value: "{{ WALG_UPLOAD_CONCURRENCY | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } - - { option: "WALG_UPLOAD_DISK_CONCURRENCY", value: "{{ WALG_UPLOAD_DISK_CONCURRENCY | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } + - { option: "WALG_UPLOAD_CONCURRENCY", value: "{{ wal_g_upload_concurrency | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } + - { option: "WALG_UPLOAD_DISK_CONCURRENCY", value: "{{ wal_g_upload_disk_concurrency | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } - { option: "PGDATA", value: "{{ postgresql_data_dir }}" } - { option: "PGHOST", value: "{{ postgresql_unix_socket_dir | default('/var/run/postgresql') }}" } - { option: "PGPORT", value: "{{ postgresql_port | default('5432') }}" } @@ -103,29 +103,30 @@ when: cloud_provider | default('') | lower == 'azure' # DigitalOcean Spaces Object Storage (if 'cloud_provider=digitalocean') -# Note: requires the Spaces access keys "AWS_ACCESS_KEY_ID" and "AWS_SECRET_ACCESS_KEY" (https://cloud.digitalocean.com/account/api/spaces) - name: "Set variable 'wal_g_json' for backup in DigitalOcean Spaces Object Storage" ansible.builtin.set_fact: wal_g_json: - - { option: "AWS_ACCESS_KEY_ID", value: "{{ WALG_AWS_ACCESS_KEY_ID | default(AWS_ACCESS_KEY_ID | default('')) }}" } - - { option: "AWS_SECRET_ACCESS_KEY", value: "{{ WALG_AWS_SECRET_ACCESS_KEY | default(AWS_SECRET_ACCESS_KEY | default('')) }}" } + - { option: "AWS_ACCESS_KEY_ID", value: "{{ wal_g_aws_access_key_id | default(digital_ocean_spaces_access_key | default(AWS_ACCESS_KEY_ID | default(''))) }}" } + - { option: "AWS_SECRET_ACCESS_KEY", value: "{{ wal_g_aws_secret_access_key | default(digital_ocean_spaces_secret_key | default(AWS_SECRET_ACCESS_KEY | default(''))) }}" } - { option: "AWS_ENDPOINT", - value: "{{ WALG_S3_ENDPOINT | default('https://' + (digital_ocean_spaces_region | default(server_location)) + '.digitaloceanspaces.com') }}", + value: "{{ wal_g_s3_endpoint | default('https://' + (digital_ocean_spaces_region | default(default_region)) + '.digitaloceanspaces.com') }}", } - - { option: "AWS_REGION", value: "{{ WALG_S3_REGION | default(digital_ocean_spaces_region | default(server_location)) }}" } + - { option: "AWS_REGION", value: "{{ wal_g_s3_region | default(digital_ocean_spaces_region | default(default_region)) }}" } - { option: "AWS_S3_FORCE_PATH_STYLE", value: "{{ AWS_S3_FORCE_PATH_STYLE | default(true) }}" } - - { option: "WALG_S3_PREFIX", value: "{{ WALG_S3_PREFIX | default('s3://' + (digital_ocean_spaces_name | default(patroni_cluster_name + '-backup'))) }}" } - - { option: "WALG_COMPRESSION_METHOD", value: "{{ WALG_COMPRESSION_METHOD | default('brotli') }}" } - - { option: "WALG_DELTA_MAX_STEPS", value: "{{ WALG_DELTA_MAX_STEPS | default('6') }}" } - - { option: "WALG_DOWNLOAD_CONCURRENCY", value: "{{ WALG_DOWNLOAD_CONCURRENCY | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } + - { option: "WALG_S3_PREFIX", value: "{{ wal_g_s3_prefix | default('s3://' + (digital_ocean_spaces_name | default(patroni_cluster_name + '-backup'))) }}" } + - { option: "WALG_COMPRESSION_METHOD", value: "{{ wal_g_compression_method | default('brotli') }}" } + - { option: "WALG_DELTA_MAX_STEPS", value: "{{ wal_g_delta_max_steps | default('6') }}" } + - { option: "WALG_DOWNLOAD_CONCURRENCY", value: "{{ wal_g_download_concurrency | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } - { option: "WALG_PREFETCH_DIR", value: "{{ wal_g_prefetch_dir_path | default(postgresql_home_dir + '/wal-g-prefetch') }}" } - - { option: "WALG_UPLOAD_CONCURRENCY", value: "{{ WALG_UPLOAD_CONCURRENCY | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } - - { option: "WALG_UPLOAD_DISK_CONCURRENCY", value: "{{ WALG_UPLOAD_DISK_CONCURRENCY | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } + - { option: "WALG_UPLOAD_CONCURRENCY", value: "{{ wal_g_upload_concurrency | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } + - { option: "WALG_UPLOAD_DISK_CONCURRENCY", value: "{{ wal_g_upload_disk_concurrency | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } - { option: "PGDATA", value: "{{ postgresql_data_dir }}" } - { option: "PGHOST", value: "{{ postgresql_unix_socket_dir | default('/var/run/postgresql') }}" } - { option: "PGPORT", value: "{{ postgresql_port | default('5432') }}" } - { option: "PGUSER", value: "{{ patroni_superuser_username | default('postgres') }}" } + vars: + default_region: "{{ (server_location in ['nyc1', 'nyc2']) | ternary('nyc3', server_location) }}" no_log: true # do not output contents to the ansible log when: cloud_provider | default('') | lower == 'digitalocean' @@ -133,28 +134,30 @@ - name: "Set variable 'wal_g_json' for backup in AWS S3 bucket" ansible.builtin.set_fact: wal_g_json: - - { option: "AWS_ACCESS_KEY_ID", value: "{{ WALG_AWS_ACCESS_KEY_ID | default(hetzner_object_storage_access_key | default('')) }}" } - - { option: "AWS_SECRET_ACCESS_KEY", value: "{{ WALG_AWS_SECRET_ACCESS_KEY | default(hetzner_object_storage_secret_key | default('')) }}" } + - { option: "AWS_ACCESS_KEY_ID", value: "{{ wal_g_aws_access_key_id | default(hetzner_object_storage_access_key | default('')) }}" } + - { option: "AWS_SECRET_ACCESS_KEY", value: "{{ wal_g_aws_secret_access_key | default(hetzner_object_storage_secret_key | default('')) }}" } - { option: "AWS_ENDPOINT", - value: "{{ WALG_S3_ENDPOINT | default(hetzner_object_storage_endpoint | default('https://' + (hetzner_object_storage_region | default(server_location)) + '.your-objectstorage.com')) }}", + value: "{{ wal_g_s3_endpoint | default(hetzner_object_storage_endpoint | default('https://' + (hetzner_object_storage_region | default(default_region)) + '.your-objectstorage.com')) }}", } - { option: "AWS_S3_FORCE_PATH_STYLE", value: "{{ AWS_S3_FORCE_PATH_STYLE | default(true) }}" } - - { option: "AWS_REGION", value: "{{ WALG_S3_REGION | default(hetzner_object_storage_region | default(server_location)) }}" } + - { option: "AWS_REGION", value: "{{ wal_g_s3_region | default(hetzner_object_storage_region | default(default_region)) }}" } - { option: "WALG_S3_PREFIX", - value: "{{ WALG_S3_PREFIX | default('s3://' + (hetzner_object_storage_name | default(patroni_cluster_name + '-backup'))) }}", + value: "{{ wal_g_s3_prefix | default('s3://' + (hetzner_object_storage_name | default(patroni_cluster_name + '-backup'))) }}", } - - { option: "WALG_COMPRESSION_METHOD", value: "{{ WALG_COMPRESSION_METHOD | default('brotli') }}" } - - { option: "WALG_DELTA_MAX_STEPS", value: "{{ WALG_DELTA_MAX_STEPS | default('6') }}" } - - { option: "WALG_DOWNLOAD_CONCURRENCY", value: "{{ WALG_DOWNLOAD_CONCURRENCY | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } + - { option: "WALG_COMPRESSION_METHOD", value: "{{ wal_g_compression_method | default('brotli') }}" } + - { option: "WALG_DELTA_MAX_STEPS", value: "{{ wal_g_delta_max_steps | default('6') }}" } + - { option: "WALG_DOWNLOAD_CONCURRENCY", value: "{{ wal_g_download_concurrency | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } - { option: "WALG_PREFETCH_DIR", value: "{{ wal_g_prefetch_dir_path | default(postgresql_home_dir + '/wal-g-prefetch') }}" } - - { option: "WALG_UPLOAD_CONCURRENCY", value: "{{ WALG_UPLOAD_CONCURRENCY | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } - - { option: "WALG_UPLOAD_DISK_CONCURRENCY", value: "{{ WALG_UPLOAD_DISK_CONCURRENCY | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } + - { option: "WALG_UPLOAD_CONCURRENCY", value: "{{ wal_g_upload_concurrency | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } + - { option: "WALG_UPLOAD_DISK_CONCURRENCY", value: "{{ wal_g_upload_disk_concurrency | default([ansible_processor_vcpus | int // 2, 1] | max) }}" } - { option: "PGDATA", value: "{{ postgresql_data_dir }}" } - { option: "PGHOST", value: "{{ postgresql_unix_socket_dir | default('/var/run/postgresql') }}" } - { option: "PGPORT", value: "{{ postgresql_port | default('5432') }}" } - { option: "PGUSER", value: "{{ patroni_superuser_username | default('postgres') }}" } + vars: + default_region: "{{ (server_location in ['hel1', 'fsn1', 'nbg1']) | ternary(server_location, 'nbg1') }}" delegate_to: localhost run_once: true # noqa run-once no_log: true # do not output contents to the ansible log diff --git a/console/db/migrations/20251103160441_2.5.0.sql b/console/db/migrations/20251103160441_2.5.0.sql index e0b3bfc750..13bc7b1eb3 100644 --- a/console/db/migrations/20251103160441_2.5.0.sql +++ b/console/db/migrations/20251103160441_2.5.0.sql @@ -18,8 +18,6 @@ values ('aws', 'Large Size', 'r7i.16xlarge', 64, 512, 4.2336, 3090.528, '$', '2025-11-03'), ('aws', 'Large Size', 'm7i.24xlarge', 96, 384, 4.8384, 3532.032, '$', '2025-11-03'), ('aws', 'Large Size', 'r7i.24xlarge', 96, 768, 6.3504, 4635.792, '$', '2025-11-03'), - ('aws', 'Large Size', 'm7i.48xlarge', 192, 768, 9.6768, 7064.064, '$', '2025-11-03'), - ('aws', 'Large Size', 'r7i.48xlarge', 192, 1536, 12.7008, 9271.584, '$', '2025-11-03'), ('gcp', 'Small Size', 'c3-standard-4', 4, 16, 0.201608, 147.17384, '$', '2025-11-03'), ('gcp', 'Small Size', 'c3-highmem-4', 4, 32, 0.264616, 193.16968, '$', '2025-11-03'), ('gcp', 'Small Size', 'c3-standard-8', 8, 32, 0.403216, 294.34768, '$', '2025-11-03'), @@ -30,8 +28,6 @@ values ('gcp', 'Medium Size', 'c3-highmem-44', 44, 352, 2.910776, 2124.86648, '$', '2025-11-03'), ('gcp', 'Medium Size', 'c3-standard-88', 88, 352, 4.435376, 3237.82448, '$', '2025-11-03'), ('gcp', 'Medium Size', 'c3-highmem-88', 88, 704, 5.821552, 4249.73296, '$', '2025-11-03'), - ('gcp', 'Large Size', 'c3-standard-176', 176, 704, 8.870752, 6475.64896, '$', '2025-11-03'), - ('gcp', 'Large Size', 'c3-highmem-176', 176, 1408, 11.643104, 8499.46592, '$', '2025-11-03'), ('gcp', 'Large Size', 'c3-standard-192-metal', 192, 768, 9.677184, 7064.34432, '$', '2025-11-03'), ('gcp', 'Large Size', 'c3-highmem-192-metal', 192, 1536, 12.701568, 9272.14464, '$', '2025-11-03'); @@ -44,4 +40,37 @@ delete from public.cloud_instances where cloud_provider = 'gcp' and instance_name like 'n2%'; +-- Update PostgreSQL max version for third-party extensions +update + public.extensions +set + postgres_max_version = '18' +where + extension_name in ('pgaudit', 'pg_cron', 'pg_partman', 'pg_repack', 'pg_stat_kcache', 'pg_wait_sampling', + 'pgvector', 'postgis', 'pgrouting', 'timescaledb'); + +-- Add new third-party extension +insert into public.extensions (extension_name, extension_description, postgres_min_version, postgres_max_version, extension_url, extension_image, contrib) + values ('pgvectorscale', 'Advanced indexing for vector data. Provided by Timescale', 13, 18, 'https://github.com/timescale/pgvectorscale', null, false); + +-- Update PostgreSQL max version for contrib extensions +update + public.extensions +set + postgres_max_version = '16' +where + extension_name in ('adminpack', 'old_snapshot'); + +-- Add new contrib extension +insert into public.extensions (extension_name, extension_description, postgres_min_version, postgres_max_version, extension_url, extension_image, contrib) + values ('pg_logicalinspect', 'functions to inspect logical decoding components', 18, null, null, null, true); + +-- Remove logo references for third-party extensions lacking an official image +update + public.extensions +set + extension_image = null +where + extension_name in ('pg_cron', 'pg_partman', 'pg_repack', 'pgvector'); + -- +goose Down diff --git a/console/service/api/swagger.yaml b/console/service/api/swagger.yaml index c7f26db8a4..fd8e4b9e3c 100644 --- a/console/service/api/swagger.yaml +++ b/console/service/api/swagger.yaml @@ -1145,9 +1145,9 @@ definitions: items: type: string extra_vars: - type: array - items: - type: string + type: object + description: "Ansible extra vars (arbitrary JSON object)" + additionalProperties: true existing_cluster: type: boolean default: false @@ -1230,9 +1230,7 @@ definitions: connection_info: type: object extra_vars: - type: array - items: - type: string + type: string description: "Ansible variables" inventory: type: string diff --git a/console/service/internal/controllers/cluster/post_cluster.go b/console/service/internal/controllers/cluster/post_cluster.go index a7f2c9161b..b0019d37b0 100644 --- a/console/service/internal/controllers/cluster/post_cluster.go +++ b/console/service/internal/controllers/cluster/post_cluster.go @@ -77,15 +77,29 @@ func (h *postClusterHandler) Handle(param cluster.PostClustersParams) middleware ansibleLogEnv := h.getAnsibleLogEnv(param.Body.Name) localLog.Trace().Strs("file_log", ansibleLogEnv).Msg("got file log name") + extraVars := map[string]interface{}{} + + if param.Body.ExtraVars != nil { + if m, ok := param.Body.ExtraVars.(map[string]interface{}); ok { + extraVars = m + } else { + localLog.Warn().Interface("extra_vars_raw", param.Body.ExtraVars).Msg("unexpected type for extra_vars, expected map[string]interface{}") + } + } + if paramLocation == EnvParamLocation { param.Body.Envs = append(param.Body.Envs, secretEnvs...) } else if paramLocation == ExtraVarsParamLocation { - param.Body.ExtraVars = append(param.Body.ExtraVars, secretEnvs...) + for _, kv := range secretEnvs { + parts := strings.SplitN(kv, "=", 2) + if len(parts) == 2 { + extraVars[parts[0]] = parts[1] + } + } } param.Body.Envs = append(param.Body.Envs, ansibleLogEnv...) - param.Body.ExtraVars = append(param.Body.ExtraVars, "patroni_cluster_name="+param.Body.Name) - h.addProxySettings(¶m, localLog) + h.addProxySettings(extraVars, ¶m, localLog) const ( LocationExtraVar = "server_location" @@ -101,8 +115,8 @@ func (h *postClusterHandler) Handle(param cluster.PostClustersParams) middleware inventoryJson InventoryJson ) - // If no cloud_provider is specified, we expect inventory to be passed - if getValFromVars(param.Body.ExtraVars, CloudProviderExtraVar) == "" { + // If no cloud_provider is specified, expect inventory (in envs) + if getValFromExtraVars(extraVars, CloudProviderExtraVar) == "" { rawInventory := getValFromVars(param.Body.Envs, InventoryJsonEnv) if rawInventory != "" { @@ -130,23 +144,31 @@ func (h *postClusterHandler) Handle(param cluster.PostClustersParams) middleware } } else { // For cloud providers, expect server count to be explicitly passed in extra vars - serverCount = getIntValFromVars(param.Body.ExtraVars, ServersExtraVar) + serverCount = getIntValFromExtraVars(extraVars, ServersExtraVar) } status := "deploying" if existing { status = "ready" } + + // extraVars + extraVarsBytes, mErr := json.Marshal(extraVars) + if mErr != nil { + localLog.Error().Err(mErr).Msg("failed to marshal extra_vars; falling back to {}") + extraVarsBytes = []byte("{}") + } + createdCluster, err := h.db.CreateCluster(param.HTTPRequest.Context(), &storage.CreateClusterReq{ ProjectID: param.Body.ProjectID, EnvironmentID: param.Body.EnvironmentID, Name: param.Body.Name, Description: param.Body.Description, SecretID: secretID, - ExtraVars: param.Body.ExtraVars, - Location: getValFromVars(param.Body.ExtraVars, LocationExtraVar), + ExtraVars: extraVarsBytes, + Location: getValFromExtraVars(extraVars, LocationExtraVar), ServerCount: serverCount, - PostgreSqlVersion: getIntValFromVars(param.Body.ExtraVars, PostgreSqlVersionExtraVar), + PostgreSqlVersion: getIntValFromExtraVars(extraVars, PostgreSqlVersionExtraVar), Status: status, Inventory: inventoryJsonVal, }) @@ -238,7 +260,7 @@ func (h *postClusterHandler) Handle(param cluster.PostClustersParams) middleware var dockerId xdocker.InstanceID dockerId, err = h.dockerManager.ManageCluster(param.HTTPRequest.Context(), &xdocker.ManageClusterConfig{ Envs: param.Body.Envs, - ExtraVars: param.Body.ExtraVars, + ExtraVars: string(extraVarsBytes), Mounts: []xdocker.Mount{ { DockerPath: ansibleLogDir, @@ -271,20 +293,16 @@ func (h *postClusterHandler) Handle(param cluster.PostClustersParams) middleware }) } -func (h *postClusterHandler) addProxySettings(param *cluster.PostClustersParams, localLog zerolog.Logger) { +func (h *postClusterHandler) addProxySettings(extraVars map[string]interface{}, param *cluster.PostClustersParams, localLog zerolog.Logger) { const proxySettingName = "proxy_env" proxySetting, err := h.db.GetSettingByName(param.HTTPRequest.Context(), proxySettingName) if err != nil { localLog.Warn().Err(err).Msg("failed to get proxy setting") + return } if proxySetting != nil { - proxySettingVal, err := json.Marshal(proxySetting.Value) - if err != nil { - localLog.Error().Any("proxy_env", proxySetting.Value).Err(err).Msg("failed to marshal proxy_env") - } else { - param.Body.ExtraVars = append(param.Body.ExtraVars, proxySettingName+"="+string(proxySettingVal)) - localLog.Info().Str("proxy_env", string(proxySettingVal)).Msg("proxy_env was added to --extra-vars") - } + extraVars[proxySettingName] = proxySetting.Value + localLog.Info().Any("proxy_env", proxySetting.Value).Msg("proxy_env added to extra_vars JSON") } } @@ -320,6 +338,55 @@ func getIntValFromVars(vars []string, key string) int { return valInt } +// JSON helpers +func getValFromExtraVars(m map[string]interface{}, key string) string { + v, ok := m[key] + if !ok || v == nil { + return "" + } + switch t := v.(type) { + case string: + return t + case float64: + return strconv.FormatFloat(t, 'f', -1, 64) + case bool: + if t { + return "true" + } + return "false" + default: + b, err := json.Marshal(t) + if err != nil { + return "" + } + return string(b) + } +} + +func getIntValFromExtraVars(m map[string]interface{}, key string) int { + v, ok := m[key] + if !ok || v == nil { + return 0 + } + + if num, ok := v.(json.Number); ok { + n, _ := strconv.Atoi(num.String()) + return n + } + + if f, ok := v.(float64); ok { + return int(f) + } + + s := fmt.Sprintf("%v", v) + + if fl, err := strconv.ParseFloat(s, 64); err == nil { + return int(fl) + } + + return 0 +} + type InventoryJson struct { All struct { Children struct { diff --git a/console/service/internal/controllers/cluster/remove_cluster.go b/console/service/internal/controllers/cluster/remove_cluster.go index 1453d1f802..a397c7a5df 100644 --- a/console/service/internal/controllers/cluster/remove_cluster.go +++ b/console/service/internal/controllers/cluster/remove_cluster.go @@ -10,6 +10,7 @@ import ( "postgresql-cluster-console/internal/xdocker" "postgresql-cluster-console/pkg/tracer" "postgresql-cluster-console/restapi/operations/cluster" + "strings" "github.com/go-openapi/runtime/middleware" "github.com/rs/zerolog" @@ -41,13 +42,16 @@ func (h *removeClusterHandler) Handle(param cluster.PostClustersIDRemoveParams) return cluster.NewPostClustersIDRemoveBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) } - var extraVars []string - - err = json.Unmarshal(clusterInfo.ExtraVars, &extraVars) - if err != nil { - return cluster.NewPostClustersIDRemoveBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + // Parse extra_vars JSON from DB + extraVarsMap := map[string]interface{}{} + if len(clusterInfo.ExtraVars) != 0 { + if err := json.Unmarshal(clusterInfo.ExtraVars, &extraVarsMap); err != nil { + return cluster.NewPostClustersIDRemoveBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } } - extraVars = append(extraVars, "state=absent") + + // Add removal state + extraVarsMap["state"] = "absent" var ( envs []string @@ -59,18 +63,30 @@ func (h *removeClusterHandler) Handle(param cluster.PostClustersIDRemoveParams) return cluster.NewPostClustersIDRemoveBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) } if paramLocation == ExtraVarsParamLocation { - extraVars = append(extraVars, envs...) + for _, kv := range envs { + parts := strings.SplitN(kv, "=", 2) + if len(parts) == 2 { + extraVarsMap[parts[0]] = parts[1] + } + } } } + envs = append(envs, "patroni_cluster_name="+clusterInfo.Name) if len(clusterInfo.Inventory) != 0 { envs = append(envs, "ANSIBLE_INVENTORY_JSON="+base64.StdEncoding.EncodeToString(clusterInfo.Inventory)) } localLog.Trace().Strs("envs", envs).Msg("got envs") + // Marshal extra vars map to JSON string for docker + extraVarsJSON, err := json.Marshal(extraVarsMap) + if err != nil { + return cluster.NewPostClustersIDRemoveBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + dockerId, err := h.dockerManager.ManageCluster(param.HTTPRequest.Context(), &xdocker.ManageClusterConfig{ Envs: envs, - ExtraVars: extraVars, + ExtraVars: string(extraVarsJSON), }) if err != nil { return cluster.NewPostClustersIDRemoveBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) diff --git a/console/service/internal/convert/clusters.go b/console/service/internal/convert/clusters.go index eb71598494..e6096825ae 100644 --- a/console/service/internal/convert/clusters.go +++ b/console/service/internal/convert/clusters.go @@ -1,7 +1,6 @@ package convert import ( - "encoding/json" "postgresql-cluster-console/internal/storage" "postgresql-cluster-console/models" @@ -29,15 +28,10 @@ func ClusterToSwagger(cl *storage.Cluster, servers []storage.Server, environment Status: cl.Status, } - // Add extra_vars - extraVars := []string{} + // Add extra_vars (as JSON string) if cl.ExtraVars != nil && len(cl.ExtraVars) > 0 { - err := json.Unmarshal(cl.ExtraVars, &extraVars) - if err != nil { - extraVars = []string{} - } + clusterInfo.ExtraVars = string(cl.ExtraVars) } - clusterInfo.ExtraVars = extraVars // Add inventory (as string) if cl.Inventory != nil && len(cl.Inventory) > 0 { diff --git a/console/service/internal/storage/models.go b/console/service/internal/storage/models.go index 17e28f98d4..8841bbd423 100644 --- a/console/service/internal/storage/models.go +++ b/console/service/internal/storage/models.go @@ -207,7 +207,7 @@ type CreateClusterReq struct { Name string Description string SecretID *int64 - ExtraVars []string + ExtraVars []byte Location string ServerCount int PostgreSqlVersion int diff --git a/console/service/internal/xdocker/imanager.go b/console/service/internal/xdocker/imanager.go index 0c609e0ea5..4a5b31f98f 100644 --- a/console/service/internal/xdocker/imanager.go +++ b/console/service/internal/xdocker/imanager.go @@ -5,7 +5,7 @@ import "context" type InstanceID string type ManageClusterConfig struct { Envs []string - ExtraVars []string + ExtraVars string // JSON string Mounts []Mount } diff --git a/console/service/internal/xdocker/manager.go b/console/service/internal/xdocker/manager.go index 470a06fc83..7e731c8486 100644 --- a/console/service/internal/xdocker/manager.go +++ b/console/service/internal/xdocker/manager.go @@ -5,6 +5,7 @@ import ( "context" "net/http" "postgresql-cluster-console/pkg/tracer" + "strings" "time" "github.com/docker/docker/api/types/container" @@ -40,7 +41,7 @@ func NewDockerManager(host string, image string) (IManager, error) { return &dockerManager{ cli: cli, log: log.Logger.With().Str("module", "docker_manager").Logger(), - image: image, + image: strings.TrimSpace(image), // trim to avoid newline / spaces }, nil } @@ -58,8 +59,9 @@ func (m *dockerManager) ManageCluster(ctx context.Context, config *ManageCluster Env: config.Envs, Cmd: func() []string { cmd := []string{entryPoint, playbookCreateCluster} - for _, vars := range config.ExtraVars { - cmd = append(cmd, "--extra-vars", vars) + + if config.ExtraVars != "" { + cmd = append(cmd, "--extra-vars", config.ExtraVars) } return cmd @@ -94,7 +96,7 @@ func (m *dockerManager) ManageCluster(ctx context.Context, config *ManageCluster if err != nil { errRem := m.cli.ContainerRemove(ctx, resp.ID, container.RemoveOptions{}) if errRem != nil { - localLog.Error().Err(err).Msg("failed to remove container after error on start") + localLog.Error().Err(errRem).Msg("failed to remove container after error on start") } return "", err diff --git a/console/service/internal/xdocker/manager_utils.go b/console/service/internal/xdocker/manager_utils.go index 50e265555d..25f9b5a721 100644 --- a/console/service/internal/xdocker/manager_utils.go +++ b/console/service/internal/xdocker/manager_utils.go @@ -11,34 +11,33 @@ import ( ) func (m *dockerManager) pullImage(ctx context.Context, dockerImage string) error { + dockerImage = strings.TrimSpace(dockerImage) localLog := m.log.With().Str("cid", ctx.Value(tracer.CtxCidKey{}).(string)).Logger() + inspectRes, _, err := m.cli.ImageInspectWithRaw(ctx, dockerImage) if err != nil { - if _, ok := err.(errdefs.ErrNotFound); !ok { - localLog.Error().Err(err).Msg("failed to inspect docker image") - + if !errdefs.IsNotFound(err) { + localLog.Error().Err(err).Str("docker_image", dockerImage).Msg("failed to inspect docker image") return err } + } else if inspectRes.ID != "" { + localLog.Info().Str("docker_image", dockerImage).Msg("docker image already present locally") + return nil } - if err == nil && inspectRes.ID != "" { - return nil // already has locally - } + out, err := m.cli.ImagePull(ctx, dockerImage, image.PullOptions{}) if err != nil { localLog.Error().Err(err).Str("docker_image", dockerImage).Msg("failed to pull docker image") - return err } - defer func() { - err = out.Close() - if err != nil { - localLog.Warn().Err(err).Msg("failed to close image_pull output") + defer func(rc io.ReadCloser) { + if cerr := rc.Close(); cerr != nil { + localLog.Warn().Err(cerr).Msg("failed to close image_pull output") } - }() + }(out) - buf := strings.Builder{} - _, _ = io.Copy(&buf, out) - localLog.Trace().Str("log", buf.String()).Msg("pull image") + _, _ = io.Copy(io.Discard, out) + localLog.Info().Str("docker_image", dockerImage).Msg("docker image successfully pulled") return nil } diff --git a/console/ui/.env b/console/ui/.env index f89b2563fe..4bb2b18eba 100644 --- a/console/ui/.env +++ b/console/ui/.env @@ -3,4 +3,4 @@ VITE_AUTH_TOKEN=auth_token VITE_CLUSTERS_POLLING_INTERVAL=60000 VITE_CLUSTER_OVERVIEW_POLLING_INTERVAL=60000 VITE_OPERATIONS_POLLING_INTERVAL=60000 -VITE_OPERATION_LOGS_POLLING_INTERVAL=10000 \ No newline at end of file +VITE_OPERATION_LOGS_POLLING_INTERVAL=10000 diff --git a/console/ui/.eslintrc.cjs b/console/ui/.eslintrc.cjs index ca2a2f7c88..6d25425003 100644 --- a/console/ui/.eslintrc.cjs +++ b/console/ui/.eslintrc.cjs @@ -1,28 +1,26 @@ module.exports = { - root: true, - env: {browser: true, es2020: true}, - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended-type-checked', - 'plugin:react-hooks/recommended', - 'plugin:@typescript-eslint/stylistic-type-checked', - 'plugin:react/recommended', - 'plugin:react/jsx-runtime' - ], - ignorePatterns: ['dist', '.eslintrc.cjs'], - parser: '@typescript-eslint/parser', - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - project: ['./tsconfig.json', './tsconfig.node.json'], - tsconfigRootDir: __dirname, - }, - plugins: ['react-refresh'], - rules: { - 'react-refresh/only-export-components': [ - 'warn', - {allowConstantExport: true}, - ], - '@typescript-eslint/no-misused-promises': 'off' - }, -} + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended-type-checked', + 'plugin:react-hooks/recommended', + 'plugin:@typescript-eslint/stylistic-type-checked', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: ['./tsconfig.json', './tsconfig.node.json'], + tsconfigRootDir: __dirname, + }, + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + '@typescript-eslint/no-misused-promises': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + }, +}; diff --git a/console/ui/package.json b/console/ui/package.json index 7ebbed281e..0640b694e8 100644 --- a/console/ui/package.json +++ b/console/ui/package.json @@ -16,70 +16,75 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@fontsource/roboto": "^5.2.6", - "@hookform/resolvers": "^5.2.1", + "@fontsource/roboto": "^5.2.8", + "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.7.0", - "@mui/icons-material": "^7.3.1", - "@mui/lab": "^7.0.0-beta.16", - "@mui/material": "^7.3.1", - "@mui/x-data-grid": "^8.10.2", - "@mui/x-date-pickers": "^8.10.2", - "@reduxjs/toolkit": "^2.8.2", + "@mui/icons-material": "^7.3.5", + "@mui/lab": "^7.0.1-beta.19", + "@mui/material": "^7.3.5", + "@mui/x-data-grid": "^8.16.0", + "@mui/x-date-pickers": "^8.16.0", + "@reduxjs/toolkit": "^2.10.1", "@tanstack/match-sorter-utils": "^8.19.4", "date-fns": "^4.1.0", - "i18next": "^25.4.0", + "i18next": "^25.6.0", "i18next-browser-languagedetector": "^8.2.0", "i18next-fs-backend": "^2.6.0", "i18next-http-backend": "^3.0.2", "ip-regex": "^5.0.0", "js-yaml": "^4.1.0", + "lodash": "^4.17.21", "material-react-table": "^3.2.1", "normalize.css": "^8.0.1", - "react": "^19.1.1", - "react-dom": "^19.1.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", "react-error-boundary": "^6.0.0", - "react-hook-form": "^7.62.0", - "react-i18next": "^15.7.1", + "react-hook-form": "^7.66.0", + "react-i18next": "^15.7.4", "react-lazylog": "^4.5.3", "react-redux": "^9.2.0", - "react-router-dom": "^7.8.2", + "react-router-dom": "^7.9.5", "react-toastify": "^11.0.5", - "yup": "^1.7.0" + "swiper": "^12.0.3", + "yaml": "^2.8.1", + "yup": "^1.7.1" }, "devDependencies": { "@faker-js/faker": "^9.9.0", "@mui/system": "^7", "@oazapfts/runtime": "^1.0.4", - "@rtk-query/codegen-openapi": "^2.0.0", + "@rtk-query/codegen-openapi": "^2.1.0", "@testing-library/dom": "^10.4.1", - "@testing-library/jest-dom": "^6.8.0", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@types/node": "^22.17.0", - "@types/react": "^19.1.11", - "@types/react-dom": "^19.1.7", + "@types/js-yaml": "^4.0.9", + "@types/lodash": "^4.17.20", + "@types/node": "^22.19.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", "@types/react-lazylog": "^4.5.4", - "@typescript-eslint/eslint-plugin": "^8.40.0", - "@typescript-eslint/parser": "^8.40.0", - "@vitejs/plugin-react": "^5.0.1", - "@vitejs/plugin-react-swc": "^4.0.1", + "@typescript-eslint/eslint-plugin": "^8.46.3", + "@typescript-eslint/parser": "^8.46.3", + "@vitejs/plugin-react": "^5.1.0", + "@vitejs/plugin-react-swc": "^4.2.0", "autoprefixer": "^10.4.21", - "esbuild": "^0.25.9", + "esbuild": "^0.25.12", "esbuild-plugin-react-virtualized": "^1.0.5", "esbuild-runner": "^2.2.2", - "eslint": "^9.34.0", + "eslint": "^9.39.1", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", + "eslint-plugin-react-refresh": "^0.4.24", "jsdom": "^26.1.0", "monaco-editor": "^0.54.0", "openapi-types": "^12.1.3", "postcss": "^8", "prettier": "^3.6.2", - "sass": "^1.90.0", - "typescript": "^5.9.2", - "vite": "^7.1.3", - "vite-plugin-svgr": "^4.3.0", + "sass": "^1.93.3", + "typescript": "^5.9.3", + "vite": "^7.2.2", + "vite-plugin-svgr": "^4.5.0", "vitest": "^3.2.4" } } diff --git a/console/ui/src/entities/authentification-method-form-block/ui/AuthenticationFormPart.tsx b/console/ui/src/entities/authentification-method-form-block/ui/AuthenticationFormPart.tsx index 2fb7f2c214..b948fd1af6 100644 --- a/console/ui/src/entities/authentification-method-form-block/ui/AuthenticationFormPart.tsx +++ b/console/ui/src/entities/authentification-method-form-block/ui/AuthenticationFormPart.tsx @@ -1,8 +1,8 @@ -import React, { FC } from 'react'; +import { FC } from 'react'; import { AUTHENTICATION_METHODS } from '@shared/model/constants.ts'; import SshMethodFormPart from '@entities/authentification-method-form-block/ui/SshMethodFormPart.tsx'; import PasswordMethodFormPart from '@entities/authentification-method-form-block/ui/PasswordMethodFormPart.tsx'; -import { Controller, useFormContext } from 'react-hook-form'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; import { TextField } from '@mui/material'; @@ -12,12 +12,10 @@ const AuthenticationFormPart: FC = () => { const { t } = useTranslation('shared'); const { control, - watch, formState: { errors }, } = useFormContext(); - const watchAuthenticationMethod = watch(CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD); - const watchIsSaveToConsole = watch(CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_IS_SAVE_TO_CONSOLE); + const watchAuthenticationMethod = useWatch({ name: CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD }); return ( <> diff --git a/console/ui/src/entities/authentification-method-form-block/ui/index.tsx b/console/ui/src/entities/authentification-method-form-block/ui/index.tsx index adc20f7085..e940ce4e39 100644 --- a/console/ui/src/entities/authentification-method-form-block/ui/index.tsx +++ b/console/ui/src/entities/authentification-method-form-block/ui/index.tsx @@ -1,8 +1,18 @@ import React, { useEffect } from 'react'; -import { Box, Checkbox, FormControlLabel, MenuItem, Radio, Stack, TextField, Typography, useTheme } from '@mui/material'; +import { + Box, + Checkbox, + FormControlLabel, + MenuItem, + Radio, + Stack, + TextField, + Typography, + useTheme, +} from '@mui/material'; import { authenticationMethods } from '@entities/authentification-method-form-block/model/constants.ts'; import { useTranslation } from 'react-i18next'; -import { Controller, useFormContext } from 'react-hook-form'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; import { useGetSecretsQuery } from '@shared/api/api/secrets.ts'; import { useAppSelector } from '@app/redux/store/hooks.ts'; @@ -17,7 +27,6 @@ const AuthenticationMethodFormBlock: React.FC = () => { const { control, - watch, resetField, setValue, formState: { errors }, @@ -25,9 +34,9 @@ const AuthenticationMethodFormBlock: React.FC = () => { const currentProject = useAppSelector(selectCurrentProject); - const watchAuthenticationMethod = watch(CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD); - const watchIsSaveToConsole = watch(CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_IS_SAVE_TO_CONSOLE); - const watchIsUseDefinedSecret = watch(CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET); + const watchAuthenticationMethod = useWatch({ name: CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD }); + const watchIsSaveToConsole = useWatch({ name: CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_IS_SAVE_TO_CONSOLE }); + const watchIsUseDefinedSecret = useWatch({ name: CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET }); const secrets = useGetSecretsQuery({ type: watchAuthenticationMethod, projectId: currentProject }); @@ -45,27 +54,28 @@ const AuthenticationMethodFormBlock: React.FC = () => { {t('authenticationMethod', { ns: 'clusters' })} - + ( <> - {authenticationMethods(t).map((method: any) => ( + {authenticationMethods(t).map(({ id, name, description }) => ( { transition: 'all 0.2s ease-in-out', }} direction="row" - onClick={() => onChange(method.id)}> - + onClick={() => onChange(id)}> + - {method.name} - {method.description} + {name} + {description} ))} @@ -102,7 +112,8 @@ const AuthenticationMethodFormBlock: React.FC = () => { helperText={errors[CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET]?.message as string}> {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} {[t('yes', { ns: 'shared' }), t('no', { ns: 'shared' })].map((option) => ( - // @ts-ignore + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error {option} diff --git a/console/ui/src/entities/cluster-cloud-provider-block/index.ts b/console/ui/src/entities/cluster-cloud-provider-block/index.ts deleted file mode 100644 index cd7e326e9d..0000000000 --- a/console/ui/src/entities/cluster-cloud-provider-block/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import ClusterFormCloudProviderBox from '@entities/cluster-cloud-provider-block/ui'; - -export default ClusterFormCloudProviderBox; diff --git a/console/ui/src/entities/cluster-cloud-provider-block/model/types.ts b/console/ui/src/entities/cluster-cloud-provider-block/model/types.ts deleted file mode 100644 index e4466d11ee..0000000000 --- a/console/ui/src/entities/cluster-cloud-provider-block/model/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ReactElement } from 'react'; - -export interface ClusterFormCloudProviderBoxProps { - children?: ReactElement; - isActive?: boolean; -} diff --git a/console/ui/src/entities/cluster-cloud-provider-block/ui/index.tsx b/console/ui/src/entities/cluster-cloud-provider-block/ui/index.tsx deleted file mode 100644 index 6c5797e65b..0000000000 --- a/console/ui/src/entities/cluster-cloud-provider-block/ui/index.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { FC } from 'react'; -import { ClusterFormCloudProviderBoxProps } from '@entities/cluster-cloud-provider-block/model/types.ts'; -import SelectableBox from '@shared/ui/selectable-box'; - -const ClusterFormCloudProviderBox: FC = ({ children, isActive, ...props }) => { - return ( - - {children} - - ); -}; - -export default ClusterFormCloudProviderBox; diff --git a/console/ui/src/entities/cluster-description-block/index.ts b/console/ui/src/entities/cluster-description-block/index.ts deleted file mode 100644 index 8670af328d..0000000000 --- a/console/ui/src/entities/cluster-description-block/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import ClusterDescriptionBlock from '@entities/cluster-description-block/ui'; - -export default ClusterDescriptionBlock; diff --git a/console/ui/src/entities/cluster-form-cloud-region-block/index.ts b/console/ui/src/entities/cluster-form-cloud-region-block/index.ts deleted file mode 100644 index eab9a9bd2a..0000000000 --- a/console/ui/src/entities/cluster-form-cloud-region-block/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import CloudFormRegionBlock from '@entities/cluster-form-cloud-region-block/ui'; - -export default CloudFormRegionBlock; diff --git a/console/ui/src/entities/cluster-form-cluster-name-block/index.ts b/console/ui/src/entities/cluster-form-cluster-name-block/index.ts deleted file mode 100644 index 79a75cb095..0000000000 --- a/console/ui/src/entities/cluster-form-cluster-name-block/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import ClusterFormClusterNameBlock from '@entities/cluster-form-cluster-name-block/ui'; - -export default ClusterFormClusterNameBlock; diff --git a/console/ui/src/entities/cluster-form-environment-block/index.ts b/console/ui/src/entities/cluster-form-environment-block/index.ts deleted file mode 100644 index 051571223d..0000000000 --- a/console/ui/src/entities/cluster-form-environment-block/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import ClusterFormEnvironmentBlock from '@entities/cluster-form-environment-block/ui'; - -export default ClusterFormEnvironmentBlock; diff --git a/console/ui/src/entities/cluster-form-instances-amount-block/index.ts b/console/ui/src/entities/cluster-form-instances-amount-block/index.ts deleted file mode 100644 index 6bc1e3a935..0000000000 --- a/console/ui/src/entities/cluster-form-instances-amount-block/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import InstancesAmountBlock from '@entities/cluster-form-instances-amount-block/ui'; - -export default InstancesAmountBlock; diff --git a/console/ui/src/entities/cluster-form-instances-amount-block/ui/index.tsx b/console/ui/src/entities/cluster-form-instances-amount-block/ui/index.tsx deleted file mode 100644 index edfed3291d..0000000000 --- a/console/ui/src/entities/cluster-form-instances-amount-block/ui/index.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { FC } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Controller, useFormContext } from 'react-hook-form'; -import { Box, Typography, useTheme } from '@mui/material'; -import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; -import ClusterSliderBox from '@shared/ui/slider-box'; -import ServersIcon from '@shared/assets/instanceIcon.svg?react'; - -const InstancesAmountBlock: FC = () => { - const { t } = useTranslation('clusters'); - const theme = useTheme(); - - const { - control, - formState: { errors }, - } = useFormContext(); - - return ( - - - {t('numberOfInstances')} - - ( - } - error={errors[CLUSTER_FORM_FIELD_NAMES.INSTANCES_AMOUNT]} - /> - )} - /> - - ); -}; - -export default InstancesAmountBlock; diff --git a/console/ui/src/entities/cluster-form-instances-block/index.ts b/console/ui/src/entities/cluster-form-instances-block/index.ts deleted file mode 100644 index 6fbdf71baa..0000000000 --- a/console/ui/src/entities/cluster-form-instances-block/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import CloudFormInstancesBlock from '@entities/cluster-form-instances-block/ui'; - -export default CloudFormInstancesBlock; diff --git a/console/ui/src/entities/cluster-form-instances-block/ui/index.tsx b/console/ui/src/entities/cluster-form-instances-block/ui/index.tsx deleted file mode 100644 index cde3ec9f7b..0000000000 --- a/console/ui/src/entities/cluster-form-instances-block/ui/index.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { FC } from 'react'; -import { TabContext, TabList, TabPanel } from '@mui/lab'; -import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; -import { Box, Divider, Stack, Tab, Typography } from '@mui/material'; -import { useTranslation } from 'react-i18next'; -import { Controller, useFormContext } from 'react-hook-form'; -import ClusterFromInstanceConfigBox from '@entities/cluster-instance-config-box'; - -const CloudFormInstancesBlock: FC = () => { - const { t } = useTranslation(['clusters', 'sharedVcpu']); - const { control, watch, setValue } = useFormContext(); - - const watchInstanceType = watch(CLUSTER_FORM_FIELD_NAMES.INSTANCE_TYPE); - - const watchProvider = watch(CLUSTER_FORM_FIELD_NAMES.PROVIDER); - - const instances = watchProvider?.instance_types ?? []; - - const handleInstanceTypeChange = (onChange: (...event: any[]) => void) => (_: any, value: string) => { - onChange(value); - setValue(CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG, instances?.[value]?.[0]); - }; - - const handleInstanceConfigChange = (onChange: (...event: any[]) => void, value: string) => () => { - onChange(value); - }; - - return ( - - - {t('selectInstanceType')} - - - { - return ( - - {Object.entries(instances)?.map(([key, value]) => - value ? : null, - )} - - ); - }} - /> - - { - return ( - <> - {Object.entries(instances).map(([key, configs]) => ( - - - {configs?.map((config) => ( - - ))} - - - ))} - - ); - }} - /> - - - ); -}; - -export default CloudFormInstancesBlock; diff --git a/console/ui/src/entities/cluster-info/index.ts b/console/ui/src/entities/cluster-info/index.ts deleted file mode 100644 index bbce068bb8..0000000000 --- a/console/ui/src/entities/cluster-info/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import ClusterInfo from '@entities/cluster-info/ui'; - -export default ClusterInfo; diff --git a/console/ui/src/entities/cluster-instance-config-box/index.ts b/console/ui/src/entities/cluster-instance-config-box/index.ts deleted file mode 100644 index 9c916eca54..0000000000 --- a/console/ui/src/entities/cluster-instance-config-box/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import ClusterFromInstanceConfigBox from '@entities/cluster-instance-config-box/ui'; - -export default ClusterFromInstanceConfigBox; diff --git a/console/ui/src/entities/cluster-name-description-block/index.ts b/console/ui/src/entities/cluster-name-description-block/index.ts deleted file mode 100644 index d150a3dc71..0000000000 --- a/console/ui/src/entities/cluster-name-description-block/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import ClusterNameDescriptionBlock from '@entities/cluster-name-description-block/ui'; - -export default ClusterNameDescriptionBlock; diff --git a/console/ui/src/entities/cluster-form-cloud-region-block/assets/aws.svg b/console/ui/src/entities/cluster/cloud-region-block/assets/aws.svg similarity index 100% rename from console/ui/src/entities/cluster-form-cloud-region-block/assets/aws.svg rename to console/ui/src/entities/cluster/cloud-region-block/assets/aws.svg diff --git a/console/ui/src/entities/cluster-form-cloud-region-block/assets/azure.svg b/console/ui/src/entities/cluster/cloud-region-block/assets/azure.svg similarity index 100% rename from console/ui/src/entities/cluster-form-cloud-region-block/assets/azure.svg rename to console/ui/src/entities/cluster/cloud-region-block/assets/azure.svg diff --git a/console/ui/src/entities/cluster-form-cloud-region-block/assets/digitalocean.svg b/console/ui/src/entities/cluster/cloud-region-block/assets/digitalocean.svg similarity index 100% rename from console/ui/src/entities/cluster-form-cloud-region-block/assets/digitalocean.svg rename to console/ui/src/entities/cluster/cloud-region-block/assets/digitalocean.svg diff --git a/console/ui/src/entities/cluster-form-cloud-region-block/assets/gcp.svg b/console/ui/src/entities/cluster/cloud-region-block/assets/gcp.svg similarity index 100% rename from console/ui/src/entities/cluster-form-cloud-region-block/assets/gcp.svg rename to console/ui/src/entities/cluster/cloud-region-block/assets/gcp.svg diff --git a/console/ui/src/entities/cluster-form-cloud-region-block/assets/hetzner.svg b/console/ui/src/entities/cluster/cloud-region-block/assets/hetzner.svg similarity index 100% rename from console/ui/src/entities/cluster-form-cloud-region-block/assets/hetzner.svg rename to console/ui/src/entities/cluster/cloud-region-block/assets/hetzner.svg diff --git a/console/ui/src/entities/cluster/cloud-region-block/index.ts b/console/ui/src/entities/cluster/cloud-region-block/index.ts new file mode 100644 index 0000000000..b06c90e41c --- /dev/null +++ b/console/ui/src/entities/cluster/cloud-region-block/index.ts @@ -0,0 +1,3 @@ +import CloudFormRegionBlock from '@entities/cluster/cloud-region-block/ui'; + +export default CloudFormRegionBlock; diff --git a/console/ui/src/entities/cluster-form-cloud-region-block/lib/hooks.tsx b/console/ui/src/entities/cluster/cloud-region-block/lib/hooks.tsx similarity index 100% rename from console/ui/src/entities/cluster-form-cloud-region-block/lib/hooks.tsx rename to console/ui/src/entities/cluster/cloud-region-block/lib/hooks.tsx diff --git a/console/ui/src/entities/cluster-form-cloud-region-block/model/types.ts b/console/ui/src/entities/cluster/cloud-region-block/model/types.ts similarity index 100% rename from console/ui/src/entities/cluster-form-cloud-region-block/model/types.ts rename to console/ui/src/entities/cluster/cloud-region-block/model/types.ts diff --git a/console/ui/src/entities/cluster-form-cloud-region-block/ui/index.tsx b/console/ui/src/entities/cluster/cloud-region-block/ui/index.tsx similarity index 90% rename from console/ui/src/entities/cluster-form-cloud-region-block/ui/index.tsx rename to console/ui/src/entities/cluster/cloud-region-block/ui/index.tsx index 709eaf2f27..2376c3fb31 100644 --- a/console/ui/src/entities/cluster-form-cloud-region-block/ui/index.tsx +++ b/console/ui/src/entities/cluster/cloud-region-block/ui/index.tsx @@ -3,15 +3,15 @@ import { TabContext, TabList, TabPanel } from '@mui/lab'; import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; import { Box, Divider, Stack, Tab, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; -import { Controller, useFormContext } from 'react-hook-form'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; import ClusterFormRegionConfigBox from '@widgets/cluster-form/ui/ClusterFormRegionConfigBox.tsx'; const CloudFormRegionBlock: FC = () => { const { t } = useTranslation('clusters'); - const { control, watch, setValue } = useFormContext(); + const { control, setValue } = useFormContext(); - const watchProvider = watch(CLUSTER_FORM_FIELD_NAMES.PROVIDER); - const regionWatch = watch(CLUSTER_FORM_FIELD_NAMES.REGION); + const watchProvider = useWatch({ name: CLUSTER_FORM_FIELD_NAMES.PROVIDER }); + const regionWatch = useWatch({ name: CLUSTER_FORM_FIELD_NAMES.REGION }); const regions = watchProvider?.cloud_regions ?? []; diff --git a/console/ui/src/entities/cluster/cluster-info/index.ts b/console/ui/src/entities/cluster/cluster-info/index.ts new file mode 100644 index 0000000000..7d55426b45 --- /dev/null +++ b/console/ui/src/entities/cluster/cluster-info/index.ts @@ -0,0 +1,3 @@ +import ClusterInfo from '@entities/cluster/cluster-info/ui'; + +export default ClusterInfo; diff --git a/console/ui/src/entities/cluster-info/lib/hooks.tsx b/console/ui/src/entities/cluster/cluster-info/lib/hooks.tsx similarity index 90% rename from console/ui/src/entities/cluster-info/lib/hooks.tsx rename to console/ui/src/entities/cluster/cluster-info/lib/hooks.tsx index 710e211820..87a57028b1 100644 --- a/console/ui/src/entities/cluster-info/lib/hooks.tsx +++ b/console/ui/src/entities/cluster/cluster-info/lib/hooks.tsx @@ -1,7 +1,6 @@ import { useTranslation } from 'react-i18next'; import { Typography } from '@mui/material'; -import { ClusterInfoProps } from '@entities/cluster-info/model/types.ts'; -import { WASI } from 'wasi'; +import { ClusterInfoProps } from '@entities/cluster/cluster-info/model/types.ts'; export const useGetClusterInfoConfig = ({ postgresVersion, diff --git a/console/ui/src/entities/cluster-info/model/types.ts b/console/ui/src/entities/cluster/cluster-info/model/types.ts similarity index 100% rename from console/ui/src/entities/cluster-info/model/types.ts rename to console/ui/src/entities/cluster/cluster-info/model/types.ts diff --git a/console/ui/src/entities/cluster-info/ui/index.tsx b/console/ui/src/entities/cluster/cluster-info/ui/index.tsx similarity index 85% rename from console/ui/src/entities/cluster-info/ui/index.tsx rename to console/ui/src/entities/cluster/cluster-info/ui/index.tsx index ca9b24e107..b7d632098f 100644 --- a/console/ui/src/entities/cluster-info/ui/index.tsx +++ b/console/ui/src/entities/cluster/cluster-info/ui/index.tsx @@ -2,9 +2,9 @@ import { FC } from 'react'; import { useTranslation } from 'react-i18next'; import { Accordion, AccordionDetails, AccordionSummary, Typography } from '@mui/material'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import { ClusterInfoProps } from '@entities/cluster-info/model/types.ts'; +import { ClusterInfoProps } from '@entities/cluster/cluster-info/model/types.ts'; import EditNoteOutlinedIcon from '@mui/icons-material/EditNoteOutlined'; -import { useGetClusterInfoConfig } from '@entities/cluster-info/lib/hooks.tsx'; +import { useGetClusterInfoConfig } from '@entities/cluster/cluster-info/lib/hooks.tsx'; import InfoCardBody from '@shared/ui/info-card-body'; const ClusterInfo: FC = ({ postgresVersion, clusterName, description, environment, location }) => { diff --git a/console/ui/src/entities/cluster/cluster-instance-config-box/index.ts b/console/ui/src/entities/cluster/cluster-instance-config-box/index.ts new file mode 100644 index 0000000000..1dfa728066 --- /dev/null +++ b/console/ui/src/entities/cluster/cluster-instance-config-box/index.ts @@ -0,0 +1,3 @@ +import ClusterFromInstanceConfigBox from '@entities/cluster/cluster-instance-config-box/ui'; + +export default ClusterFromInstanceConfigBox; diff --git a/console/ui/src/entities/cluster-instance-config-box/model/types.ts b/console/ui/src/entities/cluster/cluster-instance-config-box/model/types.ts similarity index 100% rename from console/ui/src/entities/cluster-instance-config-box/model/types.ts rename to console/ui/src/entities/cluster/cluster-instance-config-box/model/types.ts diff --git a/console/ui/src/entities/cluster-instance-config-box/ui/index.tsx b/console/ui/src/entities/cluster/cluster-instance-config-box/ui/index.tsx similarity index 88% rename from console/ui/src/entities/cluster-instance-config-box/ui/index.tsx rename to console/ui/src/entities/cluster/cluster-instance-config-box/ui/index.tsx index f30d3fdfab..9e8a84d878 100644 --- a/console/ui/src/entities/cluster-instance-config-box/ui/index.tsx +++ b/console/ui/src/entities/cluster/cluster-instance-config-box/ui/index.tsx @@ -1,9 +1,9 @@ import { FC } from 'react'; -import { ClusterFromInstanceConfigBoxProps } from '@entities/cluster-instance-config-box/model/types.ts'; +import { ClusterFromInstanceConfigBoxProps } from '@entities/cluster/cluster-instance-config-box/model/types.ts'; import SelectableBox from '@shared/ui/selectable-box'; import { Box, Stack, Typography, useTheme } from '@mui/material'; -import RamIcon from '@shared/assets/ramIcon.svg?react'; -import CpuIcon from '@shared/assets/cpuIcon.svg?react'; +import RamIcon from '@assets/ramIcon.svg?react'; +import CpuIcon from '@assets/cpuIcon.svg?react'; const ClusterFromInstanceConfigBox: FC = ({ name, diff --git a/console/ui/src/entities/cluster/cluster-name-block/index.ts b/console/ui/src/entities/cluster/cluster-name-block/index.ts new file mode 100644 index 0000000000..e0b9ac34f4 --- /dev/null +++ b/console/ui/src/entities/cluster/cluster-name-block/index.ts @@ -0,0 +1,3 @@ +import ClusterFormClusterNameBlock from '@entities/cluster/cluster-name-block/ui'; + +export default ClusterFormClusterNameBlock; diff --git a/console/ui/src/entities/cluster-form-cluster-name-block/ui/index.tsx b/console/ui/src/entities/cluster/cluster-name-block/ui/index.tsx similarity index 97% rename from console/ui/src/entities/cluster-form-cluster-name-block/ui/index.tsx rename to console/ui/src/entities/cluster/cluster-name-block/ui/index.tsx index 2f75196435..d554a5dd20 100644 --- a/console/ui/src/entities/cluster-form-cluster-name-block/ui/index.tsx +++ b/console/ui/src/entities/cluster/cluster-name-block/ui/index.tsx @@ -26,7 +26,7 @@ const ClusterFormClusterNameBlock: React.FC = () => { value={value as string} onChange={onChange} error={!!errors[CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME]} - helperText={errors[CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME]?.message ?? ''} + helperText={errors[CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME]?.message as string} /> )} /> diff --git a/console/ui/src/entities/cluster/cluster-name-description-block/index.ts b/console/ui/src/entities/cluster/cluster-name-description-block/index.ts new file mode 100644 index 0000000000..607958afe0 --- /dev/null +++ b/console/ui/src/entities/cluster/cluster-name-description-block/index.ts @@ -0,0 +1,3 @@ +import ClusterNameDescriptionBlock from '@entities/cluster/cluster-name-description-block/ui'; + +export default ClusterNameDescriptionBlock; diff --git a/console/ui/src/entities/cluster-name-description-block/ui/index.tsx b/console/ui/src/entities/cluster/cluster-name-description-block/ui/index.tsx similarity index 100% rename from console/ui/src/entities/cluster-name-description-block/ui/index.tsx rename to console/ui/src/entities/cluster/cluster-name-description-block/ui/index.tsx diff --git a/console/ui/src/entities/connection-info/assets/eyeIcon.svg b/console/ui/src/entities/cluster/connection-info/assets/eyeIcon.svg similarity index 100% rename from console/ui/src/entities/connection-info/assets/eyeIcon.svg rename to console/ui/src/entities/cluster/connection-info/assets/eyeIcon.svg diff --git a/console/ui/src/entities/cluster/connection-info/index.ts b/console/ui/src/entities/cluster/connection-info/index.ts new file mode 100644 index 0000000000..a019c5712c --- /dev/null +++ b/console/ui/src/entities/cluster/connection-info/index.ts @@ -0,0 +1,3 @@ +import ConnectionInfo from '@entities/cluster/connection-info/ui'; + +export default ConnectionInfo; diff --git a/console/ui/src/entities/connection-info/lib/hooks.tsx b/console/ui/src/entities/cluster/connection-info/lib/hooks.tsx similarity index 71% rename from console/ui/src/entities/connection-info/lib/hooks.tsx rename to console/ui/src/entities/cluster/connection-info/lib/hooks.tsx index 551f36db45..7b83a521d4 100644 --- a/console/ui/src/entities/connection-info/lib/hooks.tsx +++ b/console/ui/src/entities/cluster/connection-info/lib/hooks.tsx @@ -4,12 +4,14 @@ import { Stack, Typography } from '@mui/material'; import EyeIcon from '@mui/icons-material/VisibilityOutlined'; import CopyIcon from '@shared/ui/copy-icon'; -import ConnectionInfoRowContainer from '@entities/connection-info/ui/ConnectionInfoRowConteiner.tsx'; -import { ConnectionInfoProps } from '@entities/connection-info/model/types.ts'; +import ConnectionInfoRowContainer from '@entities/cluster/connection-info/ui/ConnectionInfoRowConteiner.tsx'; +import { ConnectionInfoProps } from '@entities/cluster/connection-info/model/types.ts'; -export const useGetConnectionInfoConfig = ( - { connectionInfo }: { connectionInfo: ConnectionInfoProps } -): { title: string; children: React.ReactNode }[] => { +export const useGetConnectionInfoConfig = ({ + connectionInfo, +}: { + connectionInfo: ConnectionInfoProps; +}): { title: string; children: React.ReactNode }[] => { const { t } = useTranslation(['clusters', 'shared']); const [isPasswordHidden, setIsPasswordHidden] = useState(true); @@ -19,15 +21,17 @@ export const useGetConnectionInfoConfig = ( const renderCollection = (collection: string | object, defaultLabel: string) => { if (typeof collection === 'string' || typeof collection === 'number') { - return [{ - title: defaultLabel, - children: ( - - {collection} - - - ), - }]; + return [ + { + title: defaultLabel, + children: ( + + {collection} + + + ), + }, + ]; } if (typeof collection === 'object' && collection !== null) { @@ -64,13 +68,10 @@ export const useGetConnectionInfoConfig = ( {isPasswordHidden ? (connectionInfo?.password || 'N/A').replace(/./g, '*') - : (connectionInfo?.password || 'N/A')} + : connectionInfo?.password || 'N/A'} - + diff --git a/console/ui/src/entities/connection-info/model/types.ts b/console/ui/src/entities/cluster/connection-info/model/types.ts similarity index 100% rename from console/ui/src/entities/connection-info/model/types.ts rename to console/ui/src/entities/cluster/connection-info/model/types.ts diff --git a/console/ui/src/entities/connection-info/ui/ConnectionInfoRowConteiner.tsx b/console/ui/src/entities/cluster/connection-info/ui/ConnectionInfoRowConteiner.tsx similarity index 77% rename from console/ui/src/entities/connection-info/ui/ConnectionInfoRowConteiner.tsx rename to console/ui/src/entities/cluster/connection-info/ui/ConnectionInfoRowConteiner.tsx index 2b2214e2b2..e85355e35f 100644 --- a/console/ui/src/entities/connection-info/ui/ConnectionInfoRowConteiner.tsx +++ b/console/ui/src/entities/cluster/connection-info/ui/ConnectionInfoRowConteiner.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; import { Stack } from '@mui/material'; -import { ConnectionInfoRowContainerProps } from '@entities/connection-info/model/types.ts'; +import { ConnectionInfoRowContainerProps } from '@entities/cluster/connection-info/model/types.ts'; const ConnectionInfoRowContainer: FC = ({ children }) => { return ( diff --git a/console/ui/src/entities/connection-info/ui/index.tsx b/console/ui/src/entities/cluster/connection-info/ui/index.tsx similarity index 83% rename from console/ui/src/entities/connection-info/ui/index.tsx rename to console/ui/src/entities/cluster/connection-info/ui/index.tsx index e15e1797db..8858b82e42 100644 --- a/console/ui/src/entities/connection-info/ui/index.tsx +++ b/console/ui/src/entities/cluster/connection-info/ui/index.tsx @@ -2,9 +2,9 @@ import { FC } from 'react'; import { Accordion, AccordionDetails, AccordionSummary, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import { ConnectionInfoProps } from '@entities/connection-info/model/types.ts'; +import { ConnectionInfoProps } from '@entities/cluster/connection-info/model/types.ts'; import PowerOutlinedIcon from '@mui/icons-material/PowerOutlined'; -import { useGetConnectionInfoConfig } from '@entities/connection-info/lib/hooks.tsx'; +import { useGetConnectionInfoConfig } from '@entities/cluster/connection-info/lib/hooks.tsx'; import InfoCardBody from '@shared/ui/info-card-body'; const ConnectionInfo: FC = ({ connectionInfo }) => { diff --git a/console/ui/src/entities/cluster/database-servers-block/index.ts b/console/ui/src/entities/cluster/database-servers-block/index.ts new file mode 100644 index 0000000000..fe1b2499df --- /dev/null +++ b/console/ui/src/entities/cluster/database-servers-block/index.ts @@ -0,0 +1,3 @@ +import DatabaseServersBlock from '@entities/cluster/database-servers-block/ui'; + +export default DatabaseServersBlock; diff --git a/console/ui/src/entities/cluster/database-servers-block/model/const.ts b/console/ui/src/entities/cluster/database-servers-block/model/const.ts new file mode 100644 index 0000000000..758a8dbfd4 --- /dev/null +++ b/console/ui/src/entities/cluster/database-servers-block/model/const.ts @@ -0,0 +1,8 @@ +export const DATABASE_SERVERS_FIELD_NAMES = Object.freeze({ + IS_CLUSTER_EXISTS: 'databaseServerExistingCluster', + DATABASE_SERVERS: 'databaseServers', + DATABASE_HOSTNAME: 'databaseServerHostname', + DATABASE_IP_ADDRESS: 'databaseServerIpAddress', + DATABASE_LOCATION: 'databaseServerLocation', + IS_POSTGRESQL_EXISTS: 'databaseServerIsPostgreSQLExist', +}); diff --git a/console/ui/src/entities/cluster/database-servers-block/model/types.ts b/console/ui/src/entities/cluster/database-servers-block/model/types.ts new file mode 100644 index 0000000000..9272862bb2 --- /dev/null +++ b/console/ui/src/entities/cluster/database-servers-block/model/types.ts @@ -0,0 +1,17 @@ +import { UseFieldArrayRemove } from 'react-hook-form'; +import { DATABASE_SERVERS_FIELD_NAMES } from '@entities/cluster/database-servers-block/model/const.ts'; + +export interface DatabaseServerBlockProps { + index: number; + remove?: UseFieldArrayRemove; +} + +export interface DatabaseServerBlockValues { + [DATABASE_SERVERS_FIELD_NAMES.IS_CLUSTER_EXISTS]?: boolean; + [DATABASE_SERVERS_FIELD_NAMES.DATABASE_SERVERS]: { + [DATABASE_SERVERS_FIELD_NAMES.DATABASE_HOSTNAME]: string; + [DATABASE_SERVERS_FIELD_NAMES.DATABASE_IP_ADDRESS]: string; + [DATABASE_SERVERS_FIELD_NAMES.DATABASE_LOCATION]: string; + [DATABASE_SERVERS_FIELD_NAMES.IS_POSTGRESQL_EXISTS]?: boolean; + }[]; +} diff --git a/console/ui/src/entities/cluster/database-servers-block/model/validation.ts b/console/ui/src/entities/cluster/database-servers-block/model/validation.ts new file mode 100644 index 0000000000..b619295897 --- /dev/null +++ b/console/ui/src/entities/cluster/database-servers-block/model/validation.ts @@ -0,0 +1,30 @@ +import * as yup from 'yup'; +import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; +import { PROVIDERS } from '@shared/config/constants.ts'; +import ipRegex from 'ip-regex'; +import { TFunction } from 'i18next'; +import { DATABASE_SERVERS_FIELD_NAMES } from '@entities/cluster/database-servers-block/model/const.ts'; + +export const databaseServersBlockValidation = (t: TFunction) => + yup.object({ + [DATABASE_SERVERS_FIELD_NAMES.DATABASE_SERVERS]: yup + .mixed() + .when(CLUSTER_FORM_FIELD_NAMES.PROVIDER, ([provider], schema) => + provider?.code === PROVIDERS.LOCAL + ? yup.array( + yup.object({ + [DATABASE_SERVERS_FIELD_NAMES.DATABASE_HOSTNAME]: yup + .string() + .required(t('requiredField', { ns: 'validation' })), + [DATABASE_SERVERS_FIELD_NAMES.DATABASE_IP_ADDRESS]: yup + .string() + .required(t('requiredField', { ns: 'validation' })) + .test('should be a correct IP', t('shouldBeACorrectV4Ip', { ns: 'validation' }), (value) => + ipRegex.v4({ exact: true }).test(value), + ), + [DATABASE_SERVERS_FIELD_NAMES.DATABASE_LOCATION]: yup.string(), + }), + ) + : schema.notRequired(), + ), + }); diff --git a/console/ui/src/entities/cluster/database-servers-block/ui/DatabaseServerBox.tsx b/console/ui/src/entities/cluster/database-servers-block/ui/DatabaseServerBox.tsx new file mode 100644 index 0000000000..59dfb456a5 --- /dev/null +++ b/console/ui/src/entities/cluster/database-servers-block/ui/DatabaseServerBox.tsx @@ -0,0 +1,108 @@ +import { FC } from 'react'; +import { DatabaseServerBlockProps } from '@entities/cluster/database-servers-block/model/types.ts'; +import { Controller, useFormContext } from 'react-hook-form'; +import { Card, Checkbox, IconButton, Stack, TextField, Tooltip, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import CloseIcon from '@mui/icons-material/Close'; +import { DATABASE_SERVERS_FIELD_NAMES } from '@entities/cluster/database-servers-block/model/const.ts'; +import { IS_EXPERT_MODE } from '@shared/model/constants.ts'; +import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; + +const DatabaseServerBox: FC = ({ index, remove }) => { + const { t } = useTranslation(['clusters', 'shared']); + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + + {remove ? ( + + + + ) : null} + + {`${t('server', { ns: 'clusters' })} ${index + 1}`} + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + {IS_EXPERT_MODE ? ( + ( + + + {t('isPostgresqlExists')} + + + + + + + )} + /> + ) : null} + + + ); +}; + +export default DatabaseServerBox; diff --git a/console/ui/src/entities/database-servers-block/ui/index.tsx b/console/ui/src/entities/cluster/database-servers-block/ui/index.tsx similarity index 59% rename from console/ui/src/entities/database-servers-block/ui/index.tsx rename to console/ui/src/entities/cluster/database-servers-block/ui/index.tsx index afcf7767b9..2fd6054795 100644 --- a/console/ui/src/entities/database-servers-block/ui/index.tsx +++ b/console/ui/src/entities/cluster/database-servers-block/ui/index.tsx @@ -1,20 +1,18 @@ import { FC } from 'react'; -import { Controller, useFormContext, useFieldArray } from 'react-hook-form'; -import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; -import DatabaseServerBox from '@entities/database-servers-block/ui/DatabaseServerBox.tsx'; -import { Checkbox, FormControlLabel, FormHelperText, Box, Button, Stack, Typography } from '@mui/material'; +import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; +import DatabaseServerBox from '@entities/cluster/database-servers-block/ui/DatabaseServerBox.tsx'; +import { Box, Button, Checkbox, FormControlLabel, Stack, Typography } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; import { useTranslation } from 'react-i18next'; +import { DATABASE_SERVERS_FIELD_NAMES } from '@entities/cluster/database-servers-block/model/const.ts'; const DatabaseServersBlock: FC = () => { const { t } = useTranslation('clusters'); - const { control, watch } = useFormContext(); - - const clusterExists = watch(CLUSTER_FORM_FIELD_NAMES.EXISTING_CLUSTER); + const { control } = useFormContext(); const { fields, append, remove } = useFieldArray({ control, - name: CLUSTER_FORM_FIELD_NAMES.DATABASE_SERVERS, + name: DATABASE_SERVERS_FIELD_NAMES.DATABASE_SERVERS, }); const removeServer = (index: number) => () => remove(index); @@ -24,34 +22,24 @@ const DatabaseServersBlock: FC = () => { {t('databaseServers')} - ( - onChange(e.target.checked)} - /> - } - label={t('clusterExistsLabel')} - /> + render={({ field }) => ( + } label={t('clusterExistsLabel')} /> )} /> {t('clusterExistsHelp')} - {fields.map((field, index) => ( ))} diff --git a/console/ui/src/entities/cluster/description-block/index.ts b/console/ui/src/entities/cluster/description-block/index.ts new file mode 100644 index 0000000000..cbc567ac91 --- /dev/null +++ b/console/ui/src/entities/cluster/description-block/index.ts @@ -0,0 +1,3 @@ +import ClusterDescriptionBlock from '@entities/cluster/description-block/ui'; + +export default ClusterDescriptionBlock; diff --git a/console/ui/src/entities/cluster-description-block/ui/index.tsx b/console/ui/src/entities/cluster/description-block/ui/index.tsx similarity index 100% rename from console/ui/src/entities/cluster-description-block/ui/index.tsx rename to console/ui/src/entities/cluster/description-block/ui/index.tsx diff --git a/console/ui/src/entities/cluster/environment-block/index.ts b/console/ui/src/entities/cluster/environment-block/index.ts new file mode 100644 index 0000000000..0d5a1d163e --- /dev/null +++ b/console/ui/src/entities/cluster/environment-block/index.ts @@ -0,0 +1,3 @@ +import ClusterFormEnvironmentBlock from '@entities/cluster/environment-block/ui'; + +export default ClusterFormEnvironmentBlock; diff --git a/console/ui/src/entities/cluster-form-environment-block/model/types.ts b/console/ui/src/entities/cluster/environment-block/model/types.ts similarity index 100% rename from console/ui/src/entities/cluster-form-environment-block/model/types.ts rename to console/ui/src/entities/cluster/environment-block/model/types.ts diff --git a/console/ui/src/entities/cluster-form-environment-block/ui/index.tsx b/console/ui/src/entities/cluster/environment-block/ui/index.tsx similarity index 81% rename from console/ui/src/entities/cluster-form-environment-block/ui/index.tsx rename to console/ui/src/entities/cluster/environment-block/ui/index.tsx index 6b4286fb25..70a7ecedc8 100644 --- a/console/ui/src/entities/cluster-form-environment-block/ui/index.tsx +++ b/console/ui/src/entities/cluster/environment-block/ui/index.tsx @@ -3,7 +3,7 @@ import { Box, MenuItem, Select, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { Controller, useFormContext } from 'react-hook-form'; import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; -import { EnvironmentBlockProps } from '@entities/cluster-form-environment-block/model/types.ts'; +import { EnvironmentBlockProps } from '@entities/cluster/environment-block/model/types.ts'; const ClusterFormEnvironmentBlock: FC = ({ environments }) => { const { t } = useTranslation('shared'); @@ -17,8 +17,8 @@ const ClusterFormEnvironmentBlock: FC = ({ environments } ( - {environments?.map((environment) => ( {environment?.name} diff --git a/console/ui/src/entities/cluster/expert-mode/additional-settings-block/model/const.ts b/console/ui/src/entities/cluster/expert-mode/additional-settings-block/model/const.ts new file mode 100644 index 0000000000..c948b2872c --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/additional-settings-block/model/const.ts @@ -0,0 +1,7 @@ +export const ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES = Object.freeze({ + SYNC_STANDBY_NODES: 'synchronousStandbyModes', + IS_SYNC_MODE_STRICT: 'isSynchronousModeStrict', + IS_DB_PUBLIC_ACCESS: 'isDbPublicAccess', + IS_CLOUD_LOAD_BALANCER: 'isCloudLoadBalancer', + IS_NETDATA_MONITORING: 'isNetdataMonitoring', +}); diff --git a/console/ui/src/entities/cluster/expert-mode/additional-settings-block/model/types.ts b/console/ui/src/entities/cluster/expert-mode/additional-settings-block/model/types.ts new file mode 100644 index 0000000000..8dbb80fd81 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/additional-settings-block/model/types.ts @@ -0,0 +1,9 @@ +import { ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/additional-settings-block/model/const.ts'; + +export interface AdditionalSettingsBlockValues { + [ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.SYNC_STANDBY_NODES]: number; + [ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.IS_SYNC_MODE_STRICT]?: string; + [ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.IS_DB_PUBLIC_ACCESS]?: number; + [ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.IS_CLOUD_LOAD_BALANCER]?: number; + [ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.IS_NETDATA_MONITORING]?: string; +} diff --git a/console/ui/src/entities/cluster/expert-mode/additional-settings-block/model/validation.ts b/console/ui/src/entities/cluster/expert-mode/additional-settings-block/model/validation.ts new file mode 100644 index 0000000000..1bf745fa86 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/additional-settings-block/model/validation.ts @@ -0,0 +1,14 @@ +import { TFunction } from 'i18next'; +import * as yup from 'yup'; +import { ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/additional-settings-block/model/const.ts'; + +export const AdditionalSettingsBlockFormSchema = (t: TFunction) => + yup.object({ + [ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.SYNC_STANDBY_NODES]: yup + .number() + .typeError(t('onlyNumbers', { ns: 'validation' })), + [ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.IS_SYNC_MODE_STRICT]: yup.boolean(), + [ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.IS_DB_PUBLIC_ACCESS]: yup.boolean(), + [ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.IS_CLOUD_LOAD_BALANCER]: yup.boolean(), + [ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.IS_NETDATA_MONITORING]: yup.boolean(), + }); diff --git a/console/ui/src/entities/cluster/expert-mode/additional-settings-block/ui/index.tsx b/console/ui/src/entities/cluster/expert-mode/additional-settings-block/ui/index.tsx new file mode 100644 index 0000000000..9284d00e78 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/additional-settings-block/ui/index.tsx @@ -0,0 +1,144 @@ +import { ChangeEvent, FC } from 'react'; +import { Checkbox, Link, Slider, Stack, TextField, Tooltip, Typography } from '@mui/material'; +import { Trans, useTranslation } from 'react-i18next'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; +import { ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/additional-settings-block/model/const.ts'; +import { INSTANCES_AMOUNT_BLOCK_VALUES } from '@entities/cluster/instances-amount-block/model/const.ts'; +import ErrorBox from '@shared/ui/error-box/ui'; +import { ErrorBoundary } from 'react-error-boundary'; +import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; +import { PROVIDERS } from '@shared/config/constants.ts'; + +const AdditionalSettingsBlock: FC = () => { + const { t } = useTranslation('clusters'); + + const { + control, + formState: { errors }, + } = useFormContext(); + + const watchInstancesAmount = useWatch({ name: INSTANCES_AMOUNT_BLOCK_VALUES.INSTANCES_AMOUNT }); + const watchSyncStandbyNodes = useWatch({ name: ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.SYNC_STANDBY_NODES }); + const watchProvider = useWatch({ name: CLUSTER_FORM_FIELD_NAMES.PROVIDER }); + + const handleInputChange = (onChange: (event: ChangeEvent) => void) => (e: ChangeEvent) => { + // prevent user from entering more or less than restricted amount in input field + const { value } = e.target; + if (value > watchInstancesAmount - 1) { + e.target.value = watchInstancesAmount - 1; + } + if (value < 0) { + e.target.value = 0; + } + onChange(e); + }; + + return ( + + + {t('additionalSettings')} + + }> + + ( + + + {t('syncStandbyNodes')} + + + + + + + + + + )} + /> + {watchSyncStandbyNodes ? ( + ( + + + {t('syncModeStrict')} + + + + + + + )} + /> + ) : null} + {watchProvider?.code !== PROVIDERS.LOCAL + ? [ + { + fieldName: ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.IS_DB_PUBLIC_ACCESS, + label: t('dbPublicAccess'), + tooltip: t('dbPublicAccessTooltip'), + }, + { + fieldName: ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.IS_CLOUD_LOAD_BALANCER, + label: t('cloudLoadBalancer'), + tooltip: t('cloudLoadBalancerTooltip'), + }, + ].map(({ fieldName, label, tooltip }) => ( + ( + + + {label} + + + + + + + )} + /> + )) + : null} + ( + + + + + + + + + + + )} + /> + + + + ); +}; + +export default AdditionalSettingsBlock; diff --git a/console/ui/src/entities/cluster/expert-mode/backups-block/model/const.ts b/console/ui/src/entities/cluster/expert-mode/backups-block/model/const.ts new file mode 100644 index 0000000000..5a23f75d80 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/backups-block/model/const.ts @@ -0,0 +1,14 @@ +export const BACKUPS_BLOCK_FIELD_NAMES = Object.freeze({ + IS_BACKUPS_ENABLED: 'isBackupsEnabled', + BACKUP_METHOD: 'backupMethod', + BACKUP_START_TIME: 'backupStartTime', + BACKUP_RETENTION: 'backupRetention', + CONFIG: 'backupConfig', + ACCESS_KEY: 'backupAccessKey', + SECRET_KEY: 'backupSecretKey', +}); + +export const BACKUP_METHODS = Object.freeze({ + PG_BACK_REST: 'pgbackrest_install', + WAL_G: 'wal_g_install', +}); diff --git a/console/ui/src/entities/cluster/expert-mode/backups-block/model/types.ts b/console/ui/src/entities/cluster/expert-mode/backups-block/model/types.ts new file mode 100644 index 0000000000..9f8ea11475 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/backups-block/model/types.ts @@ -0,0 +1,11 @@ +import { BACKUPS_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/backups-block/model/const.ts'; + +export interface BackupsBlockValues { + [BACKUPS_BLOCK_FIELD_NAMES.IS_BACKUPS_ENABLED]: boolean; + [BACKUPS_BLOCK_FIELD_NAMES.BACKUP_METHOD]?: string; + [BACKUPS_BLOCK_FIELD_NAMES.BACKUP_START_TIME]?: number; + [BACKUPS_BLOCK_FIELD_NAMES.BACKUP_RETENTION]?: number; + [BACKUPS_BLOCK_FIELD_NAMES.CONFIG]?: string; + [BACKUPS_BLOCK_FIELD_NAMES.ACCESS_KEY]?: string; + [BACKUPS_BLOCK_FIELD_NAMES.SECRET_KEY]?: string; +} diff --git a/console/ui/src/entities/cluster/expert-mode/backups-block/model/validation.ts b/console/ui/src/entities/cluster/expert-mode/backups-block/model/validation.ts new file mode 100644 index 0000000000..a0e5edb1d0 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/backups-block/model/validation.ts @@ -0,0 +1,35 @@ +import { TFunction } from 'i18next'; +import * as yup from 'yup'; +import { BACKUPS_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/backups-block/model/const.ts'; +import { configValidationSchema } from '@shared/model/validation.ts'; +import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; +import { PROVIDERS } from '@shared/config/constants.ts'; + +const backupKeysSchema = (t: TFunction) => + yup + .mixed() + .when( + [CLUSTER_FORM_FIELD_NAMES.PROVIDER, BACKUPS_BLOCK_FIELD_NAMES.IS_BACKUPS_ENABLED], + ([provider, isBackupsEnabled]) => + [PROVIDERS.DIGITAL_OCEAN, PROVIDERS.HETZNER].includes(provider?.code) && isBackupsEnabled + ? yup.string().required(t('requiredField', { ns: 'validation' })) + : yup.mixed().optional(), + ); + +export const BackupsBlockFormSchema = (t: TFunction) => + yup.object({ + [BACKUPS_BLOCK_FIELD_NAMES.IS_BACKUPS_ENABLED]: yup.boolean(), + [BACKUPS_BLOCK_FIELD_NAMES.CONFIG]: yup + .mixed() + .when( + [CLUSTER_FORM_FIELD_NAMES.PROVIDER, BACKUPS_BLOCK_FIELD_NAMES.IS_BACKUPS_ENABLED], + ([provider, isBackupsEnabled]) => + provider?.code === PROVIDERS.LOCAL && isBackupsEnabled + ? configValidationSchema(t).required(t('requiredField', { ns: 'validation' })) + : configValidationSchema(t).optional(), + ), + [BACKUPS_BLOCK_FIELD_NAMES.BACKUP_START_TIME]: yup.string(), + [BACKUPS_BLOCK_FIELD_NAMES.BACKUP_RETENTION]: yup.number().typeError(t('onlyNumbers', { ns: 'validation' })), + [BACKUPS_BLOCK_FIELD_NAMES.ACCESS_KEY]: backupKeysSchema(t), + [BACKUPS_BLOCK_FIELD_NAMES.SECRET_KEY]: backupKeysSchema(t), + }); diff --git a/console/ui/src/entities/cluster/expert-mode/backups-block/ui/ConfigureBackupModal.tsx b/console/ui/src/entities/cluster/expert-mode/backups-block/ui/ConfigureBackupModal.tsx new file mode 100644 index 0000000000..cf60edc6e6 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/backups-block/ui/ConfigureBackupModal.tsx @@ -0,0 +1,79 @@ +import { FC, useState } from 'react'; +import { Button, Card, Modal, Stack, TextField, Tooltip, Typography } from '@mui/material'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { BACKUP_METHODS, BACKUPS_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/backups-block/model/const.ts'; +import DoNotDisturbAltOutlinedIcon from '@mui/icons-material/DoNotDisturbAltOutlined'; +import DoneOutlinedIcon from '@mui/icons-material/DoneOutlined'; + +const ConfigureBackupModal: FC = () => { + const { t } = useTranslation(['clusters', 'shared']); + + const [isModalOpen, setIsModalOpen] = useState(false); + + const { + control, + formState: { errors }, + } = useFormContext(); + + const handleModalOpenState = (isOpen: boolean) => () => setIsModalOpen(isOpen); + + const watchBackupMethod = useWatch({ name: BACKUPS_BLOCK_FIELD_NAMES.BACKUP_METHOD }); + const watchConfig = useWatch({ name: BACKUPS_BLOCK_FIELD_NAMES.CONFIG }); + + return ( + <> + + + {watchConfig ? ( + + {errors?.[BACKUPS_BLOCK_FIELD_NAMES.CONFIG] ? : } + + ) : null} + + + + + + {t('configureBackup')} + + { + ( + + )} + /> + } + + + + + ); +}; + +export default ConfigureBackupModal; diff --git a/console/ui/src/entities/cluster/expert-mode/backups-block/ui/index.tsx b/console/ui/src/entities/cluster/expert-mode/backups-block/ui/index.tsx new file mode 100644 index 0000000000..1dafc31bfc --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/backups-block/ui/index.tsx @@ -0,0 +1,210 @@ +import { ChangeEvent, FC, useEffect } from 'react'; +import { + Checkbox, + FormControlLabel, + MenuItem, + Radio, + RadioGroup, + Select, + Slider, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { BACKUP_METHODS, BACKUPS_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/backups-block/model/const.ts'; +import { range } from '@mui/x-data-grid/internals'; +import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; +import ConfigureBackupModal from '@entities/cluster/expert-mode/backups-block/ui/ConfigureBackupModal.tsx'; +import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; +import { PROVIDERS } from '@shared/config/constants.ts'; + +const BackupsBlock: FC = () => { + const { t } = useTranslation('clusters'); + + const { + control, + resetField, + setValue, + formState: { errors }, + } = useFormContext(); + + const watchIsBackupsEnabled = useWatch({ name: BACKUPS_BLOCK_FIELD_NAMES.IS_BACKUPS_ENABLED }); + const watchProvider = useWatch({ name: CLUSTER_FORM_FIELD_NAMES.PROVIDER }); + + const handleInputChange = (onChange: (event: ChangeEvent) => void) => (e: ChangeEvent) => { + // prevent user from entering less than restricted amount in input field + const { value } = e.target; + if (value < 0) { + e.target.value = 0; + } + onChange(e); + }; + + useEffect(() => { + // set checkbox if user changes provider + if ([PROVIDERS.DIGITAL_OCEAN, PROVIDERS.HETZNER, PROVIDERS.LOCAL].includes(watchProvider?.code)) { + setValue(BACKUPS_BLOCK_FIELD_NAMES.IS_BACKUPS_ENABLED, false); + } + }, [watchProvider]); + + useEffect(() => { + // set checkbox if user returns from YAML editor tab + setValue(BACKUPS_BLOCK_FIELD_NAMES.IS_BACKUPS_ENABLED, watchIsBackupsEnabled); + }, []); + + return ( + + + {t('backups')} + + + ( + + + {t('backupsEnabled')} + + + + )} + /> + {watchIsBackupsEnabled ? ( + <> + ( + + + {t('backupMethod')} + + { + resetField(BACKUPS_BLOCK_FIELD_NAMES.CONFIG, { keepDirty: true }); + field.onChange(e); + }}> + {[ + { label: 'pgBackRest', value: BACKUP_METHODS.PG_BACK_REST }, + { label: 'WAL-G', value: BACKUP_METHODS.WAL_G }, + ].map(({ label, value }) => ( + } + sx={{ + '& .MuiFormControlLabel-label': { + width: 'fit-content !important', + }, + marginLeft: 0, + }} + labelPlacement="end" + /> + ))} + + + )} + /> + ( + + {t('backupStartTime')} + + + )} + /> + ( + + + {t('backupRetention')} + + + + + + + + + + )} + /> + {[PROVIDERS.DIGITAL_OCEAN, PROVIDERS.HETZNER].includes(watchProvider?.code) + ? [ + { + fieldName: BACKUPS_BLOCK_FIELD_NAMES.ACCESS_KEY, + label: t('accessKey'), + }, + { + fieldName: BACKUPS_BLOCK_FIELD_NAMES.SECRET_KEY, + label: t('secretKey'), + }, + ].map(({ fieldName, label }) => ( + ( + + + {label}* + + + + + + )} + /> + )) + : null} + + + ) : null} + + + ); +}; + +export default BackupsBlock; diff --git a/console/ui/src/entities/cluster/expert-mode/connection-pools-block/model/const.ts b/console/ui/src/entities/cluster/expert-mode/connection-pools-block/model/const.ts new file mode 100644 index 0000000000..72d4d2ba7e --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/connection-pools-block/model/const.ts @@ -0,0 +1,20 @@ +export const CONNECTION_POOLS_BLOCK_FIELD_NAMES = Object.freeze({ + IS_CONNECTION_POOLER_ENABLED: 'isConnectionPoolerEnabled', + POOLS: 'pools', + POOL_NAME: 'poolName', + POOL_SIZE: 'poolSize', + POOL_MODE: 'poolMode', +}); + +export const POOL_MODES = Object.freeze([ + { + option: 'transaction', + tooltip: 'Server is released back to pool after transaction finishes.', + }, + { option: 'session', tooltip: 'Server is released back to pool after client disconnects.' }, + { + option: 'statement', + tooltip: + 'Server is released back to pool after query finishes. Transactions spanning multiple statements are disallowed in this mode.', + }, +]); diff --git a/console/ui/src/entities/cluster/expert-mode/connection-pools-block/model/types.ts b/console/ui/src/entities/cluster/expert-mode/connection-pools-block/model/types.ts new file mode 100644 index 0000000000..a0fda3ea9c --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/connection-pools-block/model/types.ts @@ -0,0 +1,18 @@ +import { UseFieldArrayRemove } from 'react-hook-form'; +import { CONNECTION_POOLS_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/connection-pools-block/model/const.ts'; + +export interface ConnectionPoolBlockProps { + index: number; + remove?: UseFieldArrayRemove; +} + +export interface ConnectionPoolBlockValues { + [CONNECTION_POOLS_BLOCK_FIELD_NAMES.IS_CONNECTION_POOLER_ENABLED]?: boolean; + [CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOLS]: [ + { + [CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOL_NAME]?: string; + [CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOL_SIZE]?: number; + [CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOL_MODE]?: string; + }, + ]; +} diff --git a/console/ui/src/entities/cluster/expert-mode/connection-pools-block/model/validation.ts b/console/ui/src/entities/cluster/expert-mode/connection-pools-block/model/validation.ts new file mode 100644 index 0000000000..08e43de418 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/connection-pools-block/model/validation.ts @@ -0,0 +1,15 @@ +import * as yup from 'yup'; +import { TFunction } from 'i18next'; +import { CONNECTION_POOLS_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/connection-pools-block/model/const.ts'; + +export const ConnectionPoolsBlockSchema = (t: TFunction) => + yup.object({ + [CONNECTION_POOLS_BLOCK_FIELD_NAMES.IS_CONNECTION_POOLER_ENABLED]: yup.boolean(), + [CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOLS]: yup.array( + yup.object({ + [CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOL_NAME]: yup.string(), + [CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOL_SIZE]: yup.number().typeError(t('onlyNumbers', { ns: 'validation' })), + [CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOL_MODE]: yup.string(), + }), + ), + }); diff --git a/console/ui/src/entities/cluster/expert-mode/connection-pools-block/ui/ConnectionPoolBox.tsx b/console/ui/src/entities/cluster/expert-mode/connection-pools-block/ui/ConnectionPoolBox.tsx new file mode 100644 index 0000000000..9c877236b7 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/connection-pools-block/ui/ConnectionPoolBox.tsx @@ -0,0 +1,117 @@ +import { FC, useEffect } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { + Card, + FormControl, + IconButton, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import CloseIcon from '@mui/icons-material/Close'; +import { ConnectionPoolBlockProps } from '@entities/cluster/expert-mode/connection-pools-block/model/types.ts'; +import { + CONNECTION_POOLS_BLOCK_FIELD_NAMES, + POOL_MODES, +} from '@entities/cluster/expert-mode/connection-pools-block/model/const.ts'; +import { DATABASES_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/databases-block/model/const.ts'; + +const ConnectionPoolBox: FC = ({ index, remove }) => { + const { t } = useTranslation(['clusters', 'shared']); + const { + control, + setValue, + getValues, + formState: { errors }, + } = useFormContext(); + + useEffect(() => { + // set default name as corresponding database name from databases form block if available + const watchCorrespondingDatabaseName = getValues( + `${DATABASES_BLOCK_FIELD_NAMES.DATABASES}.${index}.${DATABASES_BLOCK_FIELD_NAMES.DATABASE_NAME}`, + ); + if (watchCorrespondingDatabaseName) + setValue( + `${CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOLS}.${index}.${CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOL_NAME}`, + watchCorrespondingDatabaseName, + ); + }, []); + + return ( + + {remove ? ( + + + + ) : null} + + {`${t('pool', { ns: 'clusters' })} ${index + 1}`} + + {[ + { + fieldName: CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOL_NAME, + label: t('poolName', { ns: 'clusters' }), + }, + { + fieldName: CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOL_SIZE, + label: t('poolSize', { ns: 'clusters' }), + }, + ].map(({ fieldName, label }) => ( + ( + + )} + /> + ))} + ( + + {t('poolMode')} + + + )} + /> + + + + ); +}; + +export default ConnectionPoolBox; diff --git a/console/ui/src/entities/cluster/expert-mode/connection-pools-block/ui/index.tsx b/console/ui/src/entities/cluster/expert-mode/connection-pools-block/ui/index.tsx new file mode 100644 index 0000000000..0255ea883b --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/connection-pools-block/ui/index.tsx @@ -0,0 +1,81 @@ +import { FC } from 'react'; +import { Controller, useFieldArray, useFormContext, useWatch } from 'react-hook-form'; +import { Box, Button, Checkbox, FormControlLabel, Stack, Typography } from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import { useTranslation } from 'react-i18next'; +import ConnectionPoolBox from '@entities/cluster/expert-mode/connection-pools-block/ui/ConnectionPoolBox.tsx'; +import { + CONNECTION_POOLS_BLOCK_FIELD_NAMES, + POOL_MODES, +} from '@entities/cluster/expert-mode/connection-pools-block/model/const.ts'; + +const ConnectionPoolsBlock: FC = () => { + const { t } = useTranslation('clusters'); + const { control } = useFormContext(); + + const { fields, append, remove } = useFieldArray({ + control, + name: CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOLS, + }); + + const watchIsConnectionPoolerEnabled = useWatch({ + name: CONNECTION_POOLS_BLOCK_FIELD_NAMES.IS_CONNECTION_POOLER_ENABLED, + }); + + const appendItem = () => + append({ + [CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOL_NAME]: '', + [CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOL_SIZE]: 20, + [CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOL_MODE]: POOL_MODES[0].option, + }); + + const removeServer = (index: number) => () => remove(index); + + return ( + + + {t('connectionPools')} + + + ( + } + sx={{ + marginLeft: 0, + }} + labelPlacement="start" + label={ + + {t('connectionPooler')} + + } + /> + )} + /> + {watchIsConnectionPoolerEnabled ? ( + <> + + {fields.map((field, index) => ( + + ))} + + + + ) : null} + + + ); +}; + +export default ConnectionPoolsBlock; diff --git a/console/ui/src/entities/cluster/expert-mode/data-directory-block/model/const.ts b/console/ui/src/entities/cluster/expert-mode/data-directory-block/model/const.ts new file mode 100644 index 0000000000..91d5d75f3b --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/data-directory-block/model/const.ts @@ -0,0 +1,3 @@ +export const DATA_DIRECTORY_FIELD_NAMES = Object.freeze({ + DATA_DIRECTORY: 'dataDirectory', +}); diff --git a/console/ui/src/entities/cluster/expert-mode/data-directory-block/model/types.ts b/console/ui/src/entities/cluster/expert-mode/data-directory-block/model/types.ts new file mode 100644 index 0000000000..89aec94b30 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/data-directory-block/model/types.ts @@ -0,0 +1,5 @@ +import { DATA_DIRECTORY_FIELD_NAMES } from '@entities/cluster/expert-mode/data-directory-block/model/const.ts'; + +export interface DataDirectoryFormValues { + [DATA_DIRECTORY_FIELD_NAMES.DATA_DIRECTORY]?: string; +} diff --git a/console/ui/src/entities/cluster/expert-mode/data-directory-block/ui/index.tsx b/console/ui/src/entities/cluster/expert-mode/data-directory-block/ui/index.tsx new file mode 100644 index 0000000000..bc55b8685a --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/data-directory-block/ui/index.tsx @@ -0,0 +1,46 @@ +import { FC, useEffect } from 'react'; +import { Box, TextField, Typography } from '@mui/material'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { DATA_DIRECTORY_FIELD_NAMES } from '@entities/cluster/expert-mode/data-directory-block/model/const.ts'; +import { useTranslation } from 'react-i18next'; +import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; + +const DataDirectoryBlock: FC = () => { + const { t } = useTranslation('clusters'); + + const { + control, + setValue, + formState: { errors }, + } = useFormContext(); + + const watchPostgresVersion = useWatch({ name: CLUSTER_FORM_FIELD_NAMES.POSTGRES_VERSION }); + + useEffect(() => { + setValue(DATA_DIRECTORY_FIELD_NAMES.DATA_DIRECTORY, `/pgdata/${watchPostgresVersion ?? 18}/main`); + }, [watchPostgresVersion]); + + return ( + + + {t('dataDirectory')} + + ( + + )} + /> + + ); +}; + +export default DataDirectoryBlock; diff --git a/console/ui/src/entities/cluster/expert-mode/databases-block/model/const.ts b/console/ui/src/entities/cluster/expert-mode/databases-block/model/const.ts new file mode 100644 index 0000000000..185ff48ef4 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/databases-block/model/const.ts @@ -0,0 +1,10 @@ +export const DATABASES_BLOCK_FIELD_NAMES = Object.freeze({ + DATABASES: 'databasesBlock', + NAMES: 'databasesBlockNames', + DATABASE_NAME: 'databasesBlockDatabaseName', + USER_NAME: 'databasesBlockUsername', + USER_PASSWORD: 'databasesBlockUserPassword', + ENCODING: 'databasesBlockEncoding', + LOCALE: 'databasesBlockLocale', + BLOCK_ID: 'databasesBlockId', // blockId is required to match database name and presence for extensions. Should not be passed to API call or YAML editor +}); diff --git a/console/ui/src/entities/cluster/expert-mode/databases-block/model/types.ts b/console/ui/src/entities/cluster/expert-mode/databases-block/model/types.ts new file mode 100644 index 0000000000..4fdc2db836 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/databases-block/model/types.ts @@ -0,0 +1,21 @@ +import { UseFieldArrayRemove } from 'react-hook-form'; +import { DATABASES_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/databases-block/model/const.ts'; + +export interface DatabasesBlockProps { + index: number; + remove?: UseFieldArrayRemove; +} + +export interface DatabasesBlockSingleValue { + [DATABASES_BLOCK_FIELD_NAMES.DATABASE_NAME]?: string; + [DATABASES_BLOCK_FIELD_NAMES.USER_NAME]?: string; + [DATABASES_BLOCK_FIELD_NAMES.USER_PASSWORD]?: string; + [DATABASES_BLOCK_FIELD_NAMES.ENCODING]?: string; + [DATABASES_BLOCK_FIELD_NAMES.LOCALE]?: string; + [DATABASES_BLOCK_FIELD_NAMES.BLOCK_ID]: string; +} + +export interface DatabasesBlockValues { + [DATABASES_BLOCK_FIELD_NAMES.DATABASES]?: DatabasesBlockSingleValue[]; + [DATABASES_BLOCK_FIELD_NAMES.NAMES]?: Record; +} diff --git a/console/ui/src/entities/cluster/expert-mode/databases-block/model/validation.ts b/console/ui/src/entities/cluster/expert-mode/databases-block/model/validation.ts new file mode 100644 index 0000000000..9c503cd87f --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/databases-block/model/validation.ts @@ -0,0 +1,15 @@ +import * as yup from 'yup'; +import { DATABASES_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/databases-block/model/const.ts'; + +export const DatabasesBlockSchema = yup.object({ + [DATABASES_BLOCK_FIELD_NAMES.DATABASES]: yup.array( + yup.object({ + [DATABASES_BLOCK_FIELD_NAMES.DATABASE_NAME]: yup.string(), + [DATABASES_BLOCK_FIELD_NAMES.USER_NAME]: yup.string(), + [DATABASES_BLOCK_FIELD_NAMES.USER_PASSWORD]: yup.string(), + [DATABASES_BLOCK_FIELD_NAMES.ENCODING]: yup.string(), + [DATABASES_BLOCK_FIELD_NAMES.LOCALE]: yup.string(), + [DATABASES_BLOCK_FIELD_NAMES.BLOCK_ID]: yup.string(), + }), + ), +}); diff --git a/console/ui/src/entities/cluster/expert-mode/databases-block/ui/DatabaseBox.tsx b/console/ui/src/entities/cluster/expert-mode/databases-block/ui/DatabaseBox.tsx new file mode 100644 index 0000000000..dca2068974 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/databases-block/ui/DatabaseBox.tsx @@ -0,0 +1,132 @@ +import { FC, useEffect, useState } from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { Card, IconButton, InputAdornment, Stack, TextField, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import CloseIcon from '@mui/icons-material/Close'; +import { DatabasesBlockProps } from '@entities/cluster/expert-mode/databases-block/model/types.ts'; +import { DATABASES_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/databases-block/model/const.ts'; +import Visibility from '@mui/icons-material/Visibility'; +import VisibilityOff from '@mui/icons-material/VisibilityOff'; +import { debounce } from 'lodash'; + +const DatabaseBox: FC = ({ index, remove }) => { + const { t } = useTranslation(['clusters', 'shared']); + const [isPasswordHidden, setIsPasswordHidden] = useState(true); + + const { + control, + setValue, + formState: { errors }, + } = useFormContext(); + + const togglePasswordVisibility = () => setIsPasswordHidden((prev) => !prev); + + const watchDbId = useWatch({ + name: `${DATABASES_BLOCK_FIELD_NAMES.DATABASES}.${index}.${DATABASES_BLOCK_FIELD_NAMES.BLOCK_ID}`, + }); + + const watchDbName = useWatch({ + name: `${DATABASES_BLOCK_FIELD_NAMES.DATABASES}.${index}.${DATABASES_BLOCK_FIELD_NAMES.DATABASE_NAME}`, + }); + + const watchNames = useWatch({ name: DATABASES_BLOCK_FIELD_NAMES.NAMES }); + + const debouncedSetNames = debounce((newNames) => { + setValue(DATABASES_BLOCK_FIELD_NAMES.NAMES, newNames); + }, 1000); + + useEffect(() => { + // update names on change + const newNames = { ...watchNames }; + newNames[watchDbId] = watchDbName; + setValue( + `${DATABASES_BLOCK_FIELD_NAMES.DATABASES}.${index}.${DATABASES_BLOCK_FIELD_NAMES.USER_NAME}`, + `${watchDbName}-user`, + ); + debouncedSetNames(newNames); + + return () => debouncedSetNames.cancel(); + }, [watchDbName]); + + const deleteItem = () => { + const newNames = { ...watchNames }; + delete newNames[watchDbId]; + setValue(DATABASES_BLOCK_FIELD_NAMES.NAMES, newNames); + remove?.(); + }; + + return ( + + {remove ? ( + + + + ) : null} + + {`${t('database', { ns: 'clusters' })} ${index + 1}`} + + {[ + { + fieldName: DATABASES_BLOCK_FIELD_NAMES.DATABASE_NAME, + label: t('databaseName', { ns: 'clusters' }), + }, + { + fieldName: DATABASES_BLOCK_FIELD_NAMES.USER_NAME, + label: t('username', { ns: 'shared' }), + }, + { + fieldName: DATABASES_BLOCK_FIELD_NAMES.USER_PASSWORD, + label: t('userPassword', { ns: 'shared' }), + isPassword: true, + }, + { + fieldName: DATABASES_BLOCK_FIELD_NAMES.ENCODING, + label: t('encoding', { ns: 'clusters' }), + }, + { + fieldName: DATABASES_BLOCK_FIELD_NAMES.LOCALE, + label: t('locale', { ns: 'shared' }), + }, + ].map(({ fieldName, label, isPassword }) => ( + ( + + + {isPasswordHidden ? : } + + + ), + }, + }, + } + : {})} + /> + )} + /> + ))} + + + + ); +}; + +export default DatabaseBox; diff --git a/console/ui/src/entities/cluster/expert-mode/databases-block/ui/index.tsx b/console/ui/src/entities/cluster/expert-mode/databases-block/ui/index.tsx new file mode 100644 index 0000000000..104ceebe5b --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/databases-block/ui/index.tsx @@ -0,0 +1,57 @@ +import { FC, useState } from 'react'; +import { useFieldArray, useFormContext } from 'react-hook-form'; +import { Box, Button, Stack, Typography } from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import { useTranslation } from 'react-i18next'; +import { DATABASES_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/databases-block/model/const.ts'; +import DatabaseBox from '@entities/cluster/expert-mode/databases-block/ui/DatabaseBox.tsx'; +import { uniqueId } from 'lodash'; + +const DatabaseBlock: FC = () => { + const { t } = useTranslation('clusters'); + const { control } = useFormContext(); + const [index, setIndex] = useState(2); // starts with 2 because 1 is reserved for an undeletable database + + const { fields, append, remove } = useFieldArray({ + control, + name: DATABASES_BLOCK_FIELD_NAMES.DATABASES, + }); + + const appendItem = () => { + append({ + [DATABASES_BLOCK_FIELD_NAMES.DATABASE_NAME]: `db${index}`, + [DATABASES_BLOCK_FIELD_NAMES.USER_NAME]: `db${index}-user`, + [DATABASES_BLOCK_FIELD_NAMES.USER_PASSWORD]: '', + [DATABASES_BLOCK_FIELD_NAMES.ENCODING]: 'UTF-8', + [DATABASES_BLOCK_FIELD_NAMES.LOCALE]: 'en_US.UTF-8', + [DATABASES_BLOCK_FIELD_NAMES.BLOCK_ID]: uniqueId(), + }); + setIndex((prev) => prev + 1); + }; + + const removeServer = (index: number) => () => remove(index); + + return ( + + + {t('databases')} + + + + {fields.map((field, index) => ( + + ))} + + + + + ); +}; + +export default DatabaseBlock; diff --git a/console/ui/src/entities/cluster/expert-mode/dcs-block/model/const.ts b/console/ui/src/entities/cluster/expert-mode/dcs-block/model/const.ts new file mode 100644 index 0000000000..1584b2b185 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/dcs-block/model/const.ts @@ -0,0 +1,44 @@ +export const DCS_BLOCK_FIELD_NAMES = Object.freeze({ + TYPE: 'type', + IS_DEPLOY_NEW_CLUSTER: 'isDeployNewCluster', + IS_DEPLOY_TO_DB_SERVERS: 'isDeployToDbServers', + DCS_DATABASES: 'dcsDatabases', + DCS_DATABASE_HOSTNAME: 'dcsDatabaseHostname', + DCS_DATABASE_IP_ADDRESS: 'dcsDatabaseIpAddress', + DCS_DATABASE_PORT: 'dcsDatabasePort', +}); + +export const DCS_DATABASES_DEFAULT_VALUES = Object.freeze({ + [DCS_BLOCK_FIELD_NAMES.DCS_DATABASE_HOSTNAME]: '', + [DCS_BLOCK_FIELD_NAMES.DCS_DATABASE_IP_ADDRESS]: '', + [DCS_BLOCK_FIELD_NAMES.DCS_DATABASE_PORT]: '2379', +}); + +export const DCS_TYPES = Object.freeze({ ETCD: 'etcd', CONSUL: 'consul' }); + +export const getCorrectFields = ({ watchIsDeployToDcsCluster, watchIsDeployToDbServers, watchDcsType, t }) => { + const fields = []; + + if (!watchIsDeployToDcsCluster) { + fields.push({ + fieldName: DCS_BLOCK_FIELD_NAMES.DCS_DATABASE_IP_ADDRESS, + label: t('ipAddress'), + }); + if (watchDcsType === DCS_TYPES.ETCD) { + fields.push({ fieldName: DCS_BLOCK_FIELD_NAMES.DCS_DATABASE_PORT, label: t('port') }); + } + } + if (watchIsDeployToDcsCluster && !watchIsDeployToDbServers) { + fields.push( + { + fieldName: DCS_BLOCK_FIELD_NAMES.DCS_DATABASE_HOSTNAME, + label: t('hostname'), + }, + { + fieldName: DCS_BLOCK_FIELD_NAMES.DCS_DATABASE_IP_ADDRESS, + label: t('ipAddress'), + }, + ); + } + return fields; +}; diff --git a/console/ui/src/entities/cluster/expert-mode/dcs-block/model/types.ts b/console/ui/src/entities/cluster/expert-mode/dcs-block/model/types.ts new file mode 100644 index 0000000000..29a1946a27 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/dcs-block/model/types.ts @@ -0,0 +1,19 @@ +import { UseFieldArrayRemove } from 'react-hook-form'; +import { DCS_BLOCK_FIELD_NAMES } from './const'; + +export interface DcsDatabaseBoxProps { + index: number; + remove?: UseFieldArrayRemove; + fields: Record[]; +} + +export interface DcsBlockFormValues { + [DCS_BLOCK_FIELD_NAMES.TYPE]: string; + [DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_NEW_CLUSTER]: boolean; + [DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_TO_DB_SERVERS]: boolean; + [DCS_BLOCK_FIELD_NAMES.DCS_DATABASES]: { + [DCS_BLOCK_FIELD_NAMES.DCS_DATABASE_HOSTNAME]?: string; + [DCS_BLOCK_FIELD_NAMES.DCS_DATABASE_IP_ADDRESS]?: string; + [DCS_BLOCK_FIELD_NAMES.DCS_DATABASE_PORT]?: string; + }[]; +} diff --git a/console/ui/src/entities/cluster/expert-mode/dcs-block/model/validation.ts b/console/ui/src/entities/cluster/expert-mode/dcs-block/model/validation.ts new file mode 100644 index 0000000000..0f996d9a66 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/dcs-block/model/validation.ts @@ -0,0 +1,42 @@ +import * as yup from 'yup'; +import { TFunction } from 'i18next'; +import { DCS_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/dcs-block/model/const.ts'; +import { PROVIDERS } from '@shared/config/constants.ts'; +import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; +import { IS_EXPERT_MODE } from '@shared/model/constants.ts'; + +export const DcsBlockSchema = (t: TFunction) => + yup.object({ + [DCS_BLOCK_FIELD_NAMES.TYPE]: yup.string(), + [DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_NEW_CLUSTER]: yup.boolean(), + [DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_TO_DB_SERVERS]: yup.boolean(), + [DCS_BLOCK_FIELD_NAMES.DCS_DATABASES]: yup.array( + yup.object({ + [DCS_BLOCK_FIELD_NAMES.DCS_DATABASE_HOSTNAME]: yup + .mixed() + .when( + [CLUSTER_FORM_FIELD_NAMES.PROVIDER, DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_NEW_CLUSTER], + ([provider, isDeployNewCluster]) => + provider?.code === PROVIDERS.LOCAL && IS_EXPERT_MODE && !isDeployNewCluster + ? yup.string().required(t('requiredField', { ns: 'validation' })) + : yup.mixed().optional(), + ), + [DCS_BLOCK_FIELD_NAMES.DCS_DATABASE_PORT]: yup + .mixed() + .when( + [CLUSTER_FORM_FIELD_NAMES.PROVIDER, DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_NEW_CLUSTER], + ([provider, isDeployNewCluster]) => + provider?.code === PROVIDERS.LOCAL && IS_EXPERT_MODE && !isDeployNewCluster + ? yup + .string() + .required(t('requiredField', { ns: 'validation' })) + .test( + 'should be only numbers', + t('onlyNumbers', { ns: 'validation' }), + (value) => !Number.isNaN(Number(value)), + ) + : yup.mixed().optional(), + ), + }), + ), + }); diff --git a/console/ui/src/entities/cluster/expert-mode/dcs-block/ui/DcsDatabaseBox.tsx b/console/ui/src/entities/cluster/expert-mode/dcs-block/ui/DcsDatabaseBox.tsx new file mode 100644 index 0000000000..be7a8eea01 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/dcs-block/ui/DcsDatabaseBox.tsx @@ -0,0 +1,49 @@ +import { FC, MouseEventHandler } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { Card, IconButton, Stack, TextField, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import CloseIcon from '@mui/icons-material/Close'; +import { DcsDatabaseBoxProps } from '@entities/cluster/expert-mode/dcs-block/model/types.ts'; +import { DCS_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/dcs-block/model/const.ts'; + +const DcsDatabaseBox: FC = ({ index, remove, fields = [] }) => { + const { t } = useTranslation('clusters'); + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + + {remove ? ( + }> + + + ) : null} + + {`${t('server')} ${index + 1}`} + {fields.map(({ fieldName, label }) => ( + ( + + )} + /> + ))} + + + ); +}; + +export default DcsDatabaseBox; diff --git a/console/ui/src/entities/cluster/expert-mode/dcs-block/ui/index.tsx b/console/ui/src/entities/cluster/expert-mode/dcs-block/ui/index.tsx new file mode 100644 index 0000000000..4d99b9205a --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/dcs-block/ui/index.tsx @@ -0,0 +1,128 @@ +import { FC, useEffect, useState } from 'react'; +import { + Box, + Button, + Checkbox, + FormControl, + InputLabel, + MenuItem, + Select, + Stack, + Tooltip, + Typography, +} from '@mui/material'; +import { Controller, useFieldArray, useFormContext, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; +import { + DCS_BLOCK_FIELD_NAMES, + DCS_DATABASES_DEFAULT_VALUES, + DCS_TYPES, + getCorrectFields, +} from '@entities/cluster/expert-mode/dcs-block/model/const.ts'; +import AddIcon from '@mui/icons-material/Add'; +import DcsDatabaseBox from '@entities/cluster/expert-mode/dcs-block/ui/DcsDatabaseBox.tsx'; + +const DcsBlock: FC = () => { + const { t } = useTranslation('clusters'); + const [correctFields, setCorrectFields] = useState([]); + + const { + control, + formState: { errors }, + } = useFormContext(); + + const { fields, append, remove } = useFieldArray({ + control, + name: DCS_BLOCK_FIELD_NAMES.DCS_DATABASES, + }); + + const watchIsDeployToDcsCluster = useWatch({ name: DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_NEW_CLUSTER }); + const watchIsDeployToDbServers = useWatch({ name: DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_TO_DB_SERVERS }); + const watchDcsType = useWatch({ name: DCS_BLOCK_FIELD_NAMES.TYPE }); + + const addServer = () => append(DCS_DATABASES_DEFAULT_VALUES); + + const removeServer = (index: number) => () => remove(index); + + useEffect(() => { + setCorrectFields(getCorrectFields({ watchIsDeployToDcsCluster, watchIsDeployToDbServers, watchDcsType, t })); + }, [watchIsDeployToDcsCluster, watchIsDeployToDbServers, watchDcsType]); + + return ( + + + DCS + + + ( + + {t('dcsType')} + + + )} + /> + ( + + + {t('deployNewDcsCluster')} + + + + + + + )} + /> + {watchIsDeployToDcsCluster ? ( + ( + + + {t('deployToDbServers')} + + + + + + + )} + /> + ) : null} + {correctFields?.length ? ( + + + {fields.map((field, index) => ( + + ))} + + + + ) : null} + + + ); +}; + +export default DcsBlock; diff --git a/console/ui/src/entities/cluster/expert-mode/extensions-block/assets/citus.png b/console/ui/src/entities/cluster/expert-mode/extensions-block/assets/citus.png new file mode 100644 index 0000000000..d89d4e2bb6 Binary files /dev/null and b/console/ui/src/entities/cluster/expert-mode/extensions-block/assets/citus.png differ diff --git a/console/ui/src/entities/cluster/expert-mode/extensions-block/assets/pgaudit.png b/console/ui/src/entities/cluster/expert-mode/extensions-block/assets/pgaudit.png new file mode 100644 index 0000000000..0493a237e9 Binary files /dev/null and b/console/ui/src/entities/cluster/expert-mode/extensions-block/assets/pgaudit.png differ diff --git a/console/ui/src/entities/cluster/expert-mode/extensions-block/assets/pgrouting.png b/console/ui/src/entities/cluster/expert-mode/extensions-block/assets/pgrouting.png new file mode 100644 index 0000000000..0db8389d63 Binary files /dev/null and b/console/ui/src/entities/cluster/expert-mode/extensions-block/assets/pgrouting.png differ diff --git a/console/ui/src/entities/cluster/expert-mode/extensions-block/assets/postgis.png b/console/ui/src/entities/cluster/expert-mode/extensions-block/assets/postgis.png new file mode 100644 index 0000000000..c5a115494b Binary files /dev/null and b/console/ui/src/entities/cluster/expert-mode/extensions-block/assets/postgis.png differ diff --git a/console/ui/src/entities/cluster/expert-mode/extensions-block/assets/timescaledb.png b/console/ui/src/entities/cluster/expert-mode/extensions-block/assets/timescaledb.png new file mode 100644 index 0000000000..9e2c99cd19 Binary files /dev/null and b/console/ui/src/entities/cluster/expert-mode/extensions-block/assets/timescaledb.png differ diff --git a/console/ui/src/entities/cluster/expert-mode/extensions-block/lib/functions.ts b/console/ui/src/entities/cluster/expert-mode/extensions-block/lib/functions.ts new file mode 100644 index 0000000000..ebfa322726 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/extensions-block/lib/functions.ts @@ -0,0 +1,4 @@ +import { ResponseDatabaseExtension } from '@shared/api/api/other.ts'; + +export const filterValues = (searchValue: string, extensions: ResponseDatabaseExtension[]) => + searchValue ? extensions?.filter((extension) => extension?.name?.includes(searchValue)) : extensions; diff --git a/console/ui/src/entities/cluster/expert-mode/extensions-block/model/const.ts b/console/ui/src/entities/cluster/expert-mode/extensions-block/model/const.ts new file mode 100644 index 0000000000..533c23c130 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/extensions-block/model/const.ts @@ -0,0 +1,4 @@ +export const EXTENSION_BLOCK_FIELD_NAMES = Object.freeze({ + EXTENSIONS: 'extensions', + IS_ENABLED: 'isEnabled', +}); diff --git a/console/ui/src/entities/cluster/expert-mode/extensions-block/model/types.ts b/console/ui/src/entities/cluster/expert-mode/extensions-block/model/types.ts new file mode 100644 index 0000000000..d106a25057 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/extensions-block/model/types.ts @@ -0,0 +1,19 @@ +import { EXTENSION_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/extensions-block/model/const.ts'; +import { ResponseDatabaseExtension } from '@shared/api/api/other.ts'; + +export interface ExtensionSelectorProps { + extension: ResponseDatabaseExtension; +} + +export interface ExtensionBoxProps extends ExtensionSelectorProps { + extensionIcons: Record; +} + +export interface ExtensionsSwiperProps extends Pick { + isPending: boolean; + filteredExtensions: ResponseDatabaseExtension[]; +} + +export interface ExtensionsBlockValues { + [EXTENSION_BLOCK_FIELD_NAMES.EXTENSIONS]?: ResponseDatabaseExtension[]; +} diff --git a/console/ui/src/entities/cluster/expert-mode/extensions-block/ui/ExtensionBox.tsx b/console/ui/src/entities/cluster/expert-mode/extensions-block/ui/ExtensionBox.tsx new file mode 100644 index 0000000000..af84c3594b --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/extensions-block/ui/ExtensionBox.tsx @@ -0,0 +1,48 @@ +import { FC } from 'react'; +import { Link, Stack, Tooltip, Typography, useTheme } from '@mui/material'; +import { ExtensionBoxProps } from '@entities/cluster/expert-mode/extensions-block/model/types.ts'; +import ExtensionSelector from '@entities/cluster/expert-mode/extensions-block/ui/ExtensionSelector.tsx'; + +const ExtensionBox: FC = ({ extension, extensionIcons }) => { + const theme = useTheme(); + + return ( + + {extension?.image ? ( + + img + + ) : null} + + + + {extension?.url ? ( + + {extension.name} + + ) : ( + extension.name + )} + + + + + + + {extension.description} + + + + + + ); +}; + +export default ExtensionBox; diff --git a/console/ui/src/entities/cluster/expert-mode/extensions-block/ui/ExtensionSelector.tsx b/console/ui/src/entities/cluster/expert-mode/extensions-block/ui/ExtensionSelector.tsx new file mode 100644 index 0000000000..ce7f14b0d0 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/extensions-block/ui/ExtensionSelector.tsx @@ -0,0 +1,100 @@ +import { ChangeEvent, FC, useEffect, useState } from 'react'; +import { Box, Checkbox, FormControl, ListItemText, MenuItem, Popover, Select, Switch } from '@mui/material'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { DATABASES_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/databases-block/model/const.ts'; +import { EXTENSION_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/extensions-block/model/const.ts'; +import { ExtensionSelectorProps } from '@entities/cluster/expert-mode/extensions-block/model/types.ts'; +import { intersection } from 'lodash'; + +const ExtensionSelector: FC = ({ extension }) => { + const [anchorEl, setAnchorEl] = useState(null); + const [isChecked, setIsChecked] = useState(false); + + const { control, setValue } = useFormContext(); + + const watchAvailableNames = useWatch({ name: DATABASES_BLOCK_FIELD_NAMES.NAMES }); + const watchSelectedExtensions = useWatch({ name: EXTENSION_BLOCK_FIELD_NAMES.EXTENSIONS }); + + const handleSwitchClick = (e: ChangeEvent) => { + // open Popper on click + setAnchorEl(e.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleChange = (onChange) => (e: ChangeEvent) => { + setIsChecked(!!e.target.value?.length); + onChange(e); + }; + + useEffect(() => { + if (watchSelectedExtensions?.[extension.name]) { + const intersected = intersection( + watchSelectedExtensions[extension.name], + watchAvailableNames ? Object.keys(watchAvailableNames) : [], + ); // remove db from selected if db removed + setValue(`${EXTENSION_BLOCK_FIELD_NAMES.EXTENSIONS}.${extension.name}`, intersected); + intersected?.length ? setIsChecked(true) : setIsChecked(false); + } + }, [watchAvailableNames]); + + return ( + + + {/* wrapped in Box to correctly position menu */} + + + + + + ( + + )} + /> + + + + + ); +}; + +export default ExtensionSelector; diff --git a/console/ui/src/entities/cluster/expert-mode/extensions-block/ui/ExtenstionsSwiper.tsx b/console/ui/src/entities/cluster/expert-mode/extensions-block/ui/ExtenstionsSwiper.tsx new file mode 100644 index 0000000000..0e02e337cb --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/extensions-block/ui/ExtenstionsSwiper.tsx @@ -0,0 +1,73 @@ +import { FC, useState } from 'react'; +import Spinner from '@shared/ui/spinner'; +import { IconButton, Stack, Typography, useTheme } from '@mui/material'; +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import { Grid, Pagination } from 'swiper/modules'; +import ExtensionBox from '@entities/cluster/expert-mode/extensions-block/ui/ExtensionBox.tsx'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import SwiperTypes from 'swiper'; +import { useTranslation } from 'react-i18next'; +import { ExtensionsSwiperProps } from '@entities/cluster/expert-mode/extensions-block/model/types.ts'; +import ErrorBox from '@shared/ui/error-box/ui'; +import { ErrorBoundary } from 'react-error-boundary'; + +const ExtensionsSwiper: FC = ({ isPending = false, filteredExtensions, extensionIcons }) => { + const { t } = useTranslation('clusters'); + const [swiperRef, setSwiperRef] = useState(null); + const theme = useTheme(); + + const handleSwipeNext = () => swiperRef?.slideNext(); + const handleSwipePrev = () => swiperRef?.slidePrev(); + + return ( + }> + + {isPending ? ( + + ) : ( + <> + {filteredExtensions.length ? ( + <> + + + + + {filteredExtensions?.map((extension) => ( + + + + ))} + + + + + + ) : ( + {t('noExtensionsFound')} + )} + + )} + + + ); +}; + +export default ExtensionsSwiper; diff --git a/console/ui/src/entities/cluster/expert-mode/extensions-block/ui/index.tsx b/console/ui/src/entities/cluster/expert-mode/extensions-block/ui/index.tsx new file mode 100644 index 0000000000..a3ea397eb4 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/extensions-block/ui/index.tsx @@ -0,0 +1,111 @@ +import { ChangeEvent, FC, useEffect, useRef, useState, useTransition } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Box, Checkbox, FormControlLabel, InputAdornment, Stack, TextField, Typography } from '@mui/material'; +import { ResponseDatabaseExtension, useGetDatabaseExtensionsQuery } from '@shared/api/api/other.ts'; +import Spinner from '@shared/ui/spinner'; +import { useWatch } from 'react-hook-form'; +import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; +import SearchIcon from '@mui/icons-material/Search'; +import { ErrorBoundary } from 'react-error-boundary'; +import { EXTENSION_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/extensions-block/model/const.ts'; +import { filterValues } from '@entities/cluster/expert-mode/extensions-block/lib/functions.ts'; +import 'swiper/css'; +import 'swiper/css/grid'; +import 'swiper/css/pagination'; +import './styles.css'; +import ErrorBox from '@shared/ui/error-box/ui'; +import ExtensionsSwiper from '@entities/cluster/expert-mode/extensions-block/ui/ExtenstionsSwiper.tsx'; + +const ExtensionsBlock: FC = () => { + const { t } = useTranslation('clusters'); + const [searchValue, setSearchValue] = useState(''); + const [isShowOnlyEnabled, setIsShowOnlyEnabled] = useState(false); + const [filteredExtensions, setFilteredExtensions] = useState([]); + const [pending, startTransition] = useTransition(); + + const watchPostgresVersion = useWatch({ name: CLUSTER_FORM_FIELD_NAMES.POSTGRES_VERSION }); + const watchEnabledExtensions = useWatch({ name: EXTENSION_BLOCK_FIELD_NAMES.EXTENSIONS }); + + const extensionIcons = useRef>({}); + + useEffect(() => { + extensionIcons.current = Object.entries( + import.meta.glob('../assets/*.{png,jpg,jpeg,svg,PNG,JPEG,SVG}', { + eager: true, + query: '?url', + import: 'default', + }), + ).reduce((acc, [key, value]) => { + const iconName = key.match(/(\w*.\w*)$/gi); + return iconName?.[0] ? { ...acc, [iconName[0]]: value } : acc; + }, {}); + }, []); + + const extensions = useGetDatabaseExtensionsQuery({ + postgresVersion: watchPostgresVersion, + extensionType: 'all', + limit: 999_999_999, + }); + + useEffect(() => { + startTransition(() => { + const filteredExtensions = filterValues( + searchValue, + isShowOnlyEnabled + ? (extensions.data?.data?.filter((extension) => watchEnabledExtensions?.[extension?.name]?.length) ?? []) // filter to pass only enabled extensions + : (extensions.data?.data ?? []), + ); + if (extensions.data?.data) { + setFilteredExtensions(filteredExtensions); + } + }); + }, [searchValue, isShowOnlyEnabled, extensions.data]); + + const handleSearchValueChange = (e: ChangeEvent) => setSearchValue(e.target.value); + + return extensions.isLoading ? ( + + ) : ( + }> + + + + {t('extensions')} + + + + + + ), + }, + }} + /> + setIsShowOnlyEnabled(e.target.checked)} + control={} + label={{t('showEnabled')}} + /> + + + + + + ); +}; + +export default ExtensionsBlock; diff --git a/console/ui/src/entities/cluster/expert-mode/extensions-block/ui/styles.css b/console/ui/src/entities/cluster/expert-mode/extensions-block/ui/styles.css new file mode 100644 index 0000000000..6cd2a2a98a --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/extensions-block/ui/styles.css @@ -0,0 +1,9 @@ +.swiper { + width: 100%; + margin: 0; +} + +.swiper-pagination { + position: relative; + margin-top: 24px; +} diff --git a/console/ui/src/entities/cluster/expert-mode/kernel-parameters-block/model/const.ts b/console/ui/src/entities/cluster/expert-mode/kernel-parameters-block/model/const.ts new file mode 100644 index 0000000000..81fb368b6d --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/kernel-parameters-block/model/const.ts @@ -0,0 +1,3 @@ +export const KERNEL_PARAMETERS_FIELD_NAMES = Object.freeze({ + KERNEL_PARAMETERS: 'kernelParameters', +}); diff --git a/console/ui/src/entities/cluster/expert-mode/kernel-parameters-block/model/types.ts b/console/ui/src/entities/cluster/expert-mode/kernel-parameters-block/model/types.ts new file mode 100644 index 0000000000..9296d7c5c8 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/kernel-parameters-block/model/types.ts @@ -0,0 +1,5 @@ +import { KERNEL_PARAMETERS_FIELD_NAMES } from '@entities/cluster/expert-mode/kernel-parameters-block/model/const.ts'; + +export interface KernelParametersBlockValues { + [KERNEL_PARAMETERS_FIELD_NAMES.KERNEL_PARAMETERS]?: string; +} diff --git a/console/ui/src/entities/cluster/expert-mode/kernel-parameters-block/model/validation.ts b/console/ui/src/entities/cluster/expert-mode/kernel-parameters-block/model/validation.ts new file mode 100644 index 0000000000..2971300136 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/kernel-parameters-block/model/validation.ts @@ -0,0 +1,9 @@ +import { TFunction } from 'i18next'; +import * as yup from 'yup'; +import { configValidationSchema } from '@shared/model/validation.ts'; +import { KERNEL_PARAMETERS_FIELD_NAMES } from '@entities/cluster/expert-mode/kernel-parameters-block/model/const.ts'; + +export const KernelParametersBlockFormSchema = (t: TFunction) => + yup.object({ + [KERNEL_PARAMETERS_FIELD_NAMES.KERNEL_PARAMETERS]: configValidationSchema(t).optional(), + }); diff --git a/console/ui/src/entities/cluster/expert-mode/kernel-parameters-block/ui/ConfigureKernelParametersModal.tsx b/console/ui/src/entities/cluster/expert-mode/kernel-parameters-block/ui/ConfigureKernelParametersModal.tsx new file mode 100644 index 0000000000..a9d55f9630 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/kernel-parameters-block/ui/ConfigureKernelParametersModal.tsx @@ -0,0 +1,80 @@ +import { FC, useState } from 'react'; +import { Button, Card, Modal, Stack, TextField, Tooltip, Typography } from '@mui/material'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { KERNEL_PARAMETERS_FIELD_NAMES } from '@entities/cluster/expert-mode/kernel-parameters-block/model/const.ts'; +import DoNotDisturbAltOutlinedIcon from '@mui/icons-material/DoNotDisturbAltOutlined'; +import DoneOutlinedIcon from '@mui/icons-material/DoneOutlined'; + +const ConfigureKernelParametersModal: FC = () => { + const { t } = useTranslation('clusters'); + + const [isModalOpen, setIsModalOpen] = useState(false); + + const { + control, + formState: { errors }, + } = useFormContext(); + + const handleModalOpenState = (isOpen: boolean) => () => setIsModalOpen(isOpen); + + const watchKernelParameters = useWatch({ name: KERNEL_PARAMETERS_FIELD_NAMES.KERNEL_PARAMETERS }); + + return ( + <> + + + {watchKernelParameters ? ( + + {!!errors?.[KERNEL_PARAMETERS_FIELD_NAMES.KERNEL_PARAMETERS] ? ( + + ) : ( + + )} + + ) : null} + + + + + + {t('configureKernelParameters')} + + ( + + )} + /> + + + + + ); +}; + +export default ConfigureKernelParametersModal; diff --git a/console/ui/src/entities/cluster/expert-mode/kernel-parameters-block/ui/index.tsx b/console/ui/src/entities/cluster/expert-mode/kernel-parameters-block/ui/index.tsx new file mode 100644 index 0000000000..0b5a74016f --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/kernel-parameters-block/ui/index.tsx @@ -0,0 +1,29 @@ +import { FC } from 'react'; +import { Stack, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import InfoOutlineIcon from '@mui/icons-material/InfoOutline'; +import ConfigureKernelParametersModal from '@entities/cluster/expert-mode/kernel-parameters-block/ui/ConfigureKernelParametersModal.tsx'; + +const KernelParametersBlock: FC = () => { + const { t } = useTranslation('clusters'); + + return ( + + + {t('kernelParameters')} + + + + + + {t('kernelParametersInfo')} + + {t('specifyIfQualified')} + + + + + ); +}; + +export default KernelParametersBlock; diff --git a/console/ui/src/entities/cluster/expert-mode/network-block/model/const.ts b/console/ui/src/entities/cluster/expert-mode/network-block/model/const.ts new file mode 100644 index 0000000000..3b3c063208 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/network-block/model/const.ts @@ -0,0 +1,3 @@ +export const NETWORK_BLOCK_FIELD_NAMES = Object.freeze({ + SERVER_NETWORK: 'serverNetwork', +}); diff --git a/console/ui/src/entities/cluster/expert-mode/network-block/model/types.ts b/console/ui/src/entities/cluster/expert-mode/network-block/model/types.ts new file mode 100644 index 0000000000..975f3344cb --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/network-block/model/types.ts @@ -0,0 +1,5 @@ +import { NETWORK_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/network-block/model/const.ts'; + +export interface NetworkBlockValues { + [NETWORK_BLOCK_FIELD_NAMES.SERVER_NETWORK]?: string; +} diff --git a/console/ui/src/entities/cluster/expert-mode/network-block/model/validation.ts b/console/ui/src/entities/cluster/expert-mode/network-block/model/validation.ts new file mode 100644 index 0000000000..f3dcd5d5ad --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/network-block/model/validation.ts @@ -0,0 +1,8 @@ +import { TFunction } from 'i18next'; +import * as yup from 'yup'; +import { NETWORK_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/network-block/model/const.ts'; + +export const NetworkBlockFormSchema = (t: TFunction) => + yup.object({ + [NETWORK_BLOCK_FIELD_NAMES.SERVER_NETWORK]: yup.string(), + }); diff --git a/console/ui/src/entities/cluster/expert-mode/network-block/ui/index.tsx b/console/ui/src/entities/cluster/expert-mode/network-block/ui/index.tsx new file mode 100644 index 0000000000..eb77cbdc09 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/network-block/ui/index.tsx @@ -0,0 +1,36 @@ +import { FC } from 'react'; +import { Stack, TextField, Typography } from '@mui/material'; +import { Controller, useFormContext } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { NETWORK_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/network-block/model/const.ts'; + +const Network: FC = () => { + const { t } = useTranslation('clusters'); + + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + + {t('network')} + {t('networkInfo')} + ( + + )} + /> + + ); +}; + +export default Network; diff --git a/console/ui/src/entities/cluster/expert-mode/postgres-parameters-block/model/const.ts b/console/ui/src/entities/cluster/expert-mode/postgres-parameters-block/model/const.ts new file mode 100644 index 0000000000..20d4e1140f --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/postgres-parameters-block/model/const.ts @@ -0,0 +1,3 @@ +export const POSTGRES_PARAMETERS_FIELD_NAMES = Object.freeze({ + POSTGRES_PARAMETERS: 'postgresParameters', +}); diff --git a/console/ui/src/entities/cluster/expert-mode/postgres-parameters-block/model/types.ts b/console/ui/src/entities/cluster/expert-mode/postgres-parameters-block/model/types.ts new file mode 100644 index 0000000000..58ab10af89 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/postgres-parameters-block/model/types.ts @@ -0,0 +1,5 @@ +import { POSTGRES_PARAMETERS_FIELD_NAMES } from '@entities/cluster/expert-mode/postgres-parameters-block/model/const.ts'; + +export interface PostgresParametersBlockValues { + [POSTGRES_PARAMETERS_FIELD_NAMES.POSTGRES_PARAMETERS]?: string; +} diff --git a/console/ui/src/entities/cluster/expert-mode/postgres-parameters-block/model/validation.ts b/console/ui/src/entities/cluster/expert-mode/postgres-parameters-block/model/validation.ts new file mode 100644 index 0000000000..8f5e24beaf --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/postgres-parameters-block/model/validation.ts @@ -0,0 +1,9 @@ +import { TFunction } from 'i18next'; +import * as yup from 'yup'; +import { POSTGRES_PARAMETERS_FIELD_NAMES } from '@entities/cluster/expert-mode/postgres-parameters-block/model/const.ts'; +import { configValidationSchema } from '@shared/model/validation.ts'; + +export const PostgresParametersBlockFormSchema = (t: TFunction) => + yup.object({ + [POSTGRES_PARAMETERS_FIELD_NAMES.POSTGRES_PARAMETERS]: configValidationSchema(t).optional(), + }); diff --git a/console/ui/src/entities/cluster/expert-mode/postgres-parameters-block/ui/ConfigurePostgresParametersModal.tsx b/console/ui/src/entities/cluster/expert-mode/postgres-parameters-block/ui/ConfigurePostgresParametersModal.tsx new file mode 100644 index 0000000000..f3025e655e --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/postgres-parameters-block/ui/ConfigurePostgresParametersModal.tsx @@ -0,0 +1,80 @@ +import { FC, useState } from 'react'; +import { Button, Card, Modal, Stack, TextField, Tooltip, Typography } from '@mui/material'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { POSTGRES_PARAMETERS_FIELD_NAMES } from '@entities/cluster/expert-mode/postgres-parameters-block/model/const.ts'; +import DoNotDisturbAltOutlinedIcon from '@mui/icons-material/DoNotDisturbAltOutlined'; +import DoneOutlinedIcon from '@mui/icons-material/DoneOutlined'; + +const ConfigurePostgresParametersModal: FC = () => { + const { t } = useTranslation(['clusters', 'shared']); + + const [isModalOpen, setIsModalOpen] = useState(false); + + const { + control, + formState: { errors }, + } = useFormContext(); + + const handleModalOpenState = (isOpen: boolean) => () => setIsModalOpen(isOpen); + + const watchPostgresParameters = useWatch({ name: POSTGRES_PARAMETERS_FIELD_NAMES.POSTGRES_PARAMETERS }); + + return ( + <> + + + {watchPostgresParameters ? ( + + {errors?.[POSTGRES_PARAMETERS_FIELD_NAMES.POSTGRES_PARAMETERS] ? ( + + ) : ( + + )} + + ) : null} + + + + + + {t('configurePostgresParameters')} + + ( + + )} + /> + + + + + ); +}; + +export default ConfigurePostgresParametersModal; diff --git a/console/ui/src/entities/cluster/expert-mode/postgres-parameters-block/ui/index.tsx b/console/ui/src/entities/cluster/expert-mode/postgres-parameters-block/ui/index.tsx new file mode 100644 index 0000000000..58d769ca30 --- /dev/null +++ b/console/ui/src/entities/cluster/expert-mode/postgres-parameters-block/ui/index.tsx @@ -0,0 +1,29 @@ +import { FC } from 'react'; +import { Stack, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import ConfigurePostgresParametersModal from '@entities/cluster/expert-mode/postgres-parameters-block/ui/ConfigurePostgresParametersModal.tsx'; +import InfoOutlineIcon from '@mui/icons-material/InfoOutline'; + +const PostgresParametersBlock: FC = () => { + const { t } = useTranslation('clusters'); + + return ( + + + {t('postgresParameters')} + + + + + + {t('postgresParametersInfo')} + + {t('specifyIfQualified')} + + + + + ); +}; + +export default PostgresParametersBlock; diff --git a/console/ui/src/entities/cluster-form-instances-amount-block/assets/instancesIcon.svg b/console/ui/src/entities/cluster/instances-amount-block/assets/instancesIcon.svg similarity index 100% rename from console/ui/src/entities/cluster-form-instances-amount-block/assets/instancesIcon.svg rename to console/ui/src/entities/cluster/instances-amount-block/assets/instancesIcon.svg diff --git a/console/ui/src/entities/cluster/instances-amount-block/index.ts b/console/ui/src/entities/cluster/instances-amount-block/index.ts new file mode 100644 index 0000000000..7c96a715ad --- /dev/null +++ b/console/ui/src/entities/cluster/instances-amount-block/index.ts @@ -0,0 +1,3 @@ +import InstancesAmountBlock from '@entities/cluster/instances-amount-block/ui'; + +export default InstancesAmountBlock; diff --git a/console/ui/src/entities/cluster/instances-amount-block/model/const.ts b/console/ui/src/entities/cluster/instances-amount-block/model/const.ts new file mode 100644 index 0000000000..c0c9774efd --- /dev/null +++ b/console/ui/src/entities/cluster/instances-amount-block/model/const.ts @@ -0,0 +1,4 @@ +export const INSTANCES_AMOUNT_BLOCK_VALUES = Object.freeze({ + INSTANCES_AMOUNT: 'instancesAmount', + IS_SPOT_INSTANCES: 'isSpotInstances', +}); diff --git a/console/ui/src/entities/cluster/instances-amount-block/ui/index.tsx b/console/ui/src/entities/cluster/instances-amount-block/ui/index.tsx new file mode 100644 index 0000000000..f120630bc6 --- /dev/null +++ b/console/ui/src/entities/cluster/instances-amount-block/ui/index.tsx @@ -0,0 +1,81 @@ +import { FC, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { Box, Checkbox, FormControlLabel, Tooltip, Typography, useTheme } from '@mui/material'; +import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; +import ClusterSliderBox from '@shared/ui/slider-box'; +import ServersIcon from '@assets/instanceIcon.svg?react'; +import { IS_EXPERT_MODE } from '@shared/model/constants.ts'; +import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; +import { PROVIDERS } from '@shared/config/constants.ts'; +import { INSTANCES_AMOUNT_BLOCK_VALUES } from '@entities/cluster/instances-amount-block/model/const.ts'; + +const InstancesAmountBlock: FC = () => { + const { t } = useTranslation('clusters'); + const theme = useTheme(); + const [isRightElementNeeded, setIsRightElementNeeded] = useState(false); + + const { + control, + formState: { errors }, + } = useFormContext(); + + const watchProvider = useWatch({ name: CLUSTER_FORM_FIELD_NAMES.PROVIDER }); + + useEffect(() => { + setIsRightElementNeeded([PROVIDERS.AWS, PROVIDERS.GCP, PROVIDERS.AZURE].includes(watchProvider?.code)); + }, [watchProvider]); + + return ( + + + {t('numberOfInstances')} + + ( + } + error={errors[INSTANCES_AMOUNT_BLOCK_VALUES.INSTANCES_AMOUNT]} + topRightElements={ + IS_EXPERT_MODE && isRightElementNeeded ? ( + ( + } + sx={{ + marginLeft: 0, + }} + labelPlacement="start" + label={ + + {t('spotInstances')} + + + + + } + /> + )} + /> + ) : null + } + /> + )} + /> + + ); +}; + +export default InstancesAmountBlock; diff --git a/console/ui/src/entities/cluster/instances-block/index.ts b/console/ui/src/entities/cluster/instances-block/index.ts new file mode 100644 index 0000000000..b0dd56c7e5 --- /dev/null +++ b/console/ui/src/entities/cluster/instances-block/index.ts @@ -0,0 +1,3 @@ +import CloudFormInstancesBlock from '@entities/cluster/instances-block/ui'; + +export default CloudFormInstancesBlock; diff --git a/console/ui/src/entities/cluster/instances-block/model/const.ts b/console/ui/src/entities/cluster/instances-block/model/const.ts new file mode 100644 index 0000000000..d094a0dba2 --- /dev/null +++ b/console/ui/src/entities/cluster/instances-block/model/const.ts @@ -0,0 +1,4 @@ +export const INSTANCES_BLOCK_FIELD_NAMES = Object.freeze({ + INSTANCE_TYPE: 'instanceType', + SERVER_TYPE: 'serverType', +}); diff --git a/console/ui/src/entities/cluster-form-instances-block/model/types.ts b/console/ui/src/entities/cluster/instances-block/model/types.ts similarity index 72% rename from console/ui/src/entities/cluster-form-instances-block/model/types.ts rename to console/ui/src/entities/cluster/instances-block/model/types.ts index b560ec09a9..fff718236f 100644 --- a/console/ui/src/entities/cluster-form-instances-block/model/types.ts +++ b/console/ui/src/entities/cluster/instances-block/model/types.ts @@ -1,4 +1,4 @@ -import { DeploymentInstanceType } from '@shared/api/api/other.ts'; +import { DeploymentInstanceType } from '@/shared/api/api/deployments'; export interface CloudFormInstancesBlockProps { instances: { diff --git a/console/ui/src/entities/cluster/instances-block/ui/CustomInstance.tsx b/console/ui/src/entities/cluster/instances-block/ui/CustomInstance.tsx new file mode 100644 index 0000000000..fba95fe797 --- /dev/null +++ b/console/ui/src/entities/cluster/instances-block/ui/CustomInstance.tsx @@ -0,0 +1,36 @@ +import React, { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Controller, useFormContext } from 'react-hook-form'; +import { Stack, TextField, Typography } from '@mui/material'; +import { INSTANCES_BLOCK_FIELD_NAMES } from '@entities/cluster/instances-block/model/const.ts'; + +const CustomInstance: FC = () => { + const { t } = useTranslation('clusters'); + + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + + {t('customInstanceTypeInfo')} + ( + + )} + /> + + ); +}; + +export default CustomInstance; diff --git a/console/ui/src/entities/cluster/instances-block/ui/index.tsx b/console/ui/src/entities/cluster/instances-block/ui/index.tsx new file mode 100644 index 0000000000..acf278d0f6 --- /dev/null +++ b/console/ui/src/entities/cluster/instances-block/ui/index.tsx @@ -0,0 +1,103 @@ +import { FC, ReactNode, useEffect, useState } from 'react'; +import { TabContext, TabList, TabPanel } from '@mui/lab'; +import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; +import { Box, Divider, Stack, Tab, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import ClusterFromInstanceConfigBox from '@entities/cluster/cluster-instance-config-box'; +import ErrorBox from '@shared/ui/error-box/ui'; +import { ErrorBoundary } from 'react-error-boundary'; +import { IS_EXPERT_MODE } from '@shared/model/constants.ts'; +import CustomInstance from '@entities/cluster/instances-block/ui/CustomInstance.tsx'; + +const CloudFormInstancesBlock: FC = () => { + const { t } = useTranslation('clusters'); + const { control, setValue } = useFormContext(); + const [instances, setInstances] = useState({}); + + const watchInstanceType = useWatch({ name: CLUSTER_FORM_FIELD_NAMES.INSTANCE_TYPE }); + + const watchProvider = useWatch({ name: CLUSTER_FORM_FIELD_NAMES.PROVIDER }); + + useEffect(() => { + const providerInstanceTypes = watchProvider?.instance_types ?? {}; + const instanceTypes = IS_EXPERT_MODE + ? { + ...providerInstanceTypes, + custom: ( + + + + + + ), + } + : providerInstanceTypes; + setInstances(instanceTypes); + }, [watchProvider?.instance_types]); + + const handleInstanceTypeChange = (onChange: (...event: any[]) => void) => (_: any, value: string) => { + onChange(value); + setValue(CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG, instances?.[value]?.[0]); + }; + + const handleInstanceConfigChange = (onChange: (...event: any[]) => void, value: string) => () => { + onChange(value); + }; + + return ( + + + {t('selectInstanceType')} + + }> + + { + return ( + + {Object.entries(instances)?.map(([key, value]) => + value ? : null, + )} + + ); + }} + /> + + { + return ( + <> + {Object.entries(instances).map(([key, configs]) => ( + + + {Array.isArray(configs) + ? configs?.map((config) => ( + + )) + : (configs as ReactNode)} + + + ))} + + ); + }} + /> + + + + ); +}; + +export default CloudFormInstancesBlock; diff --git a/console/ui/src/entities/cluster/load-balancers-block/index.ts b/console/ui/src/entities/cluster/load-balancers-block/index.ts new file mode 100644 index 0000000000..196f47640a --- /dev/null +++ b/console/ui/src/entities/cluster/load-balancers-block/index.ts @@ -0,0 +1,3 @@ +import LoadBalancersBlock from '@entities/cluster/load-balancers-block/ui'; + +export default LoadBalancersBlock; diff --git a/console/ui/src/entities/cluster/load-balancers-block/model/const.ts b/console/ui/src/entities/cluster/load-balancers-block/model/const.ts new file mode 100644 index 0000000000..9df837c0db --- /dev/null +++ b/console/ui/src/entities/cluster/load-balancers-block/model/const.ts @@ -0,0 +1,12 @@ +export const LOAD_BALANCERS_FIELD_NAMES = Object.freeze({ + IS_HAPROXY_ENABLED: 'isHaproxyEnabled', + IS_DEPLOY_TO_DATABASE_SERVERS: 'isDeployToDatabaseServers', + DATABASES: 'loadBalancerDatabases', + DATABASES_HOSTNAME: 'loadBalancerDatabasesHostname', + DATABASES_ADDRESS: 'loadBalancerDatabasesAddress', +}); + +export const LOAD_BALANCERS_DATABASES_DEFAULT_VALUES = Object.freeze({ + [LOAD_BALANCERS_FIELD_NAMES.DATABASES_HOSTNAME]: '', + [LOAD_BALANCERS_FIELD_NAMES.DATABASES_ADDRESS]: '', +}); diff --git a/console/ui/src/entities/cluster/load-balancers-block/model/types.ts b/console/ui/src/entities/cluster/load-balancers-block/model/types.ts new file mode 100644 index 0000000000..784017688a --- /dev/null +++ b/console/ui/src/entities/cluster/load-balancers-block/model/types.ts @@ -0,0 +1,16 @@ +import { UseFieldArrayRemove } from 'react-hook-form'; +import { LOAD_BALANCERS_FIELD_NAMES } from './const'; + +export interface LoadBalancersDatabaseBoxProps { + index: number; + remove?: UseFieldArrayRemove; +} + +export interface LoadBalancersBlockValues { + [LOAD_BALANCERS_FIELD_NAMES.IS_HAPROXY_ENABLED]: boolean; + [LOAD_BALANCERS_FIELD_NAMES.IS_DEPLOY_TO_DATABASE_SERVERS]?: boolean; + [LOAD_BALANCERS_FIELD_NAMES.DATABASES]: { + [LOAD_BALANCERS_FIELD_NAMES.DATABASES_HOSTNAME]?: string; + [LOAD_BALANCERS_FIELD_NAMES.DATABASES_ADDRESS]?: string; + }[]; +} diff --git a/console/ui/src/entities/cluster/load-balancers-block/model/validation.ts b/console/ui/src/entities/cluster/load-balancers-block/model/validation.ts new file mode 100644 index 0000000000..0f0c3dc75f --- /dev/null +++ b/console/ui/src/entities/cluster/load-balancers-block/model/validation.ts @@ -0,0 +1,34 @@ +import * as yup from 'yup'; +import { TFunction } from 'i18next'; +import { LOAD_BALANCERS_FIELD_NAMES } from '@entities/cluster/load-balancers-block/model/const.ts'; +import ipRegex from 'ip-regex'; +import { IS_EXPERT_MODE } from '@shared/model/constants.ts'; + +export const LoadBalancerBlockSchema = (t: TFunction) => + yup.object({ + [LOAD_BALANCERS_FIELD_NAMES.IS_HAPROXY_ENABLED]: yup.boolean(), + [LOAD_BALANCERS_FIELD_NAMES.IS_DEPLOY_TO_DATABASE_SERVERS]: yup.boolean(), + [LOAD_BALANCERS_FIELD_NAMES.DATABASES]: yup.array( + yup.object({ + [LOAD_BALANCERS_FIELD_NAMES.DATABASES_HOSTNAME]: yup + .mixed() + .when(LOAD_BALANCERS_FIELD_NAMES.IS_DEPLOY_TO_DATABASE_SERVERS, ([isDeployToDbServers]) => + !isDeployToDbServers && IS_EXPERT_MODE + ? yup.string().required(t('requiredField', { ns: 'validation' })) + : yup.mixed().optional(), + ), + [LOAD_BALANCERS_FIELD_NAMES.DATABASES_ADDRESS]: yup + .mixed() + .when(LOAD_BALANCERS_FIELD_NAMES.IS_DEPLOY_TO_DATABASE_SERVERS, ([isDeployToDbServers]) => + !isDeployToDbServers && IS_EXPERT_MODE + ? yup + .string() + .required(t('requiredField', { ns: 'validation' })) + .test('should be a correct IP', t('shouldBeACorrectV4Ip', { ns: 'validation' }), (value) => + ipRegex.v4({ exact: true }).test(value), + ) + : yup.mixed().optional(), + ), + }), + ), + }); diff --git a/console/ui/src/entities/cluster/load-balancers-block/ui/LoadBalancersDatabaseBox.tsx b/console/ui/src/entities/cluster/load-balancers-block/ui/LoadBalancersDatabaseBox.tsx new file mode 100644 index 0000000000..2d4242d86f --- /dev/null +++ b/console/ui/src/entities/cluster/load-balancers-block/ui/LoadBalancersDatabaseBox.tsx @@ -0,0 +1,68 @@ +import { FC } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { Card, IconButton, Stack, TextField, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import CloseIcon from '@mui/icons-material/Close'; +import { LOAD_BALANCERS_FIELD_NAMES } from '@entities/cluster/load-balancers-block/model/const.ts'; +import { LoadBalancersDatabaseBoxProps } from '@entities/cluster/load-balancers-block/model/types.ts'; + +const LoadBalancersDatabaseBox: FC = ({ index, remove }) => { + const { t } = useTranslation('clusters'); + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + + {remove ? ( + + + + ) : null} + + {`${t('server')} ${index + 1}`} + ( + + )} + /> + ( + + )} + /> + + + ); +}; + +export default LoadBalancersDatabaseBox; diff --git a/console/ui/src/entities/cluster/load-balancers-block/ui/index.tsx b/console/ui/src/entities/cluster/load-balancers-block/ui/index.tsx new file mode 100644 index 0000000000..58210775fb --- /dev/null +++ b/console/ui/src/entities/cluster/load-balancers-block/ui/index.tsx @@ -0,0 +1,100 @@ +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Controller, useFieldArray, useFormContext, useWatch } from 'react-hook-form'; +import { Box, Button, Checkbox, Stack, Tooltip, Typography } from '@mui/material'; +import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; +import { + LOAD_BALANCERS_DATABASES_DEFAULT_VALUES, + LOAD_BALANCERS_FIELD_NAMES, +} from '@entities/cluster/load-balancers-block/model/const.ts'; +import { IS_EXPERT_MODE } from '@shared/model/constants.ts'; +import AddIcon from '@mui/icons-material/Add'; +import LoadBalancersDatabaseBox from '@entities/cluster/load-balancers-block/ui/LoadBalancersDatabaseBox.tsx'; + +const LoadBalancersBlock: FC = () => { + const { t } = useTranslation('clusters'); + const { control } = useFormContext(); + + const watchIsHaproxyEnabled = useWatch({ name: LOAD_BALANCERS_FIELD_NAMES.IS_HAPROXY_ENABLED }); + const watchIsDeployToDatabases = useWatch({ name: LOAD_BALANCERS_FIELD_NAMES.IS_DEPLOY_TO_DATABASE_SERVERS }); + + const { fields, append, remove } = useFieldArray({ + control, + name: LOAD_BALANCERS_FIELD_NAMES.DATABASES, + }); + + const removeServer = (index: number) => () => remove(index); + + const addServer = () => append(LOAD_BALANCERS_DATABASES_DEFAULT_VALUES); + + return ( + + + {t('loadBalancers')} + + + {[ + { + fieldName: LOAD_BALANCERS_FIELD_NAMES.IS_HAPROXY_ENABLED, + label: t('haproxyLoadBalancer'), + tooltip: t('haproxyLoadBalancerTooltip'), + }, + ].map(({ fieldName, label, tooltip }) => ( + ( + + + {label} + + + + + + + )} + /> + ))} + {IS_EXPERT_MODE ? ( + watchIsHaproxyEnabled ? ( + ( + + + {t('deployToDatabaseServers')} + + + + + + + )} + /> + ) : null + ) : null} + {IS_EXPERT_MODE && watchIsHaproxyEnabled && !watchIsDeployToDatabases ? ( + + + {fields.map((field, index) => ( + + ))} + + + + ) : null} + + + ); +}; + +export default LoadBalancersBlock; diff --git a/console/ui/src/entities/cluster/postgres-version-block/index.ts b/console/ui/src/entities/cluster/postgres-version-block/index.ts new file mode 100644 index 0000000000..59f0d69885 --- /dev/null +++ b/console/ui/src/entities/cluster/postgres-version-block/index.ts @@ -0,0 +1,3 @@ +import PostgresVersionBox from '@entities/cluster/postgres-version-block/ui'; + +export default PostgresVersionBox; diff --git a/console/ui/src/entities/postgres-version-block/model/types.ts b/console/ui/src/entities/cluster/postgres-version-block/model/types.ts similarity index 100% rename from console/ui/src/entities/postgres-version-block/model/types.ts rename to console/ui/src/entities/cluster/postgres-version-block/model/types.ts diff --git a/console/ui/src/entities/postgres-version-block/ui/index.tsx b/console/ui/src/entities/cluster/postgres-version-block/ui/index.tsx similarity index 93% rename from console/ui/src/entities/postgres-version-block/ui/index.tsx rename to console/ui/src/entities/cluster/postgres-version-block/ui/index.tsx index 23bc1a952f..4a0df27154 100644 --- a/console/ui/src/entities/postgres-version-block/ui/index.tsx +++ b/console/ui/src/entities/cluster/postgres-version-block/ui/index.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Controller, useFormContext } from 'react-hook-form'; import { Box, MenuItem, Select, Typography } from '@mui/material'; import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; -import { PostgresVersionBlockProps } from '@entities/postgres-version-block/model/types.ts'; +import { PostgresVersionBlockProps } from '@entities/cluster/postgres-version-block/model/types.ts'; const PostgresVersionBox: FC = ({ postgresVersions }) => { const { t } = useTranslation('clusters'); diff --git a/console/ui/src/entities/cluster/providers-block/index.ts b/console/ui/src/entities/cluster/providers-block/index.ts new file mode 100644 index 0000000000..e37277095e --- /dev/null +++ b/console/ui/src/entities/cluster/providers-block/index.ts @@ -0,0 +1,3 @@ +import ClusterFormProvidersBlock from '@entities/cluster/providers-block/ui'; + +export default ClusterFormProvidersBlock; diff --git a/console/ui/src/entities/providers-block/model/types.ts b/console/ui/src/entities/cluster/providers-block/model/types.ts similarity index 100% rename from console/ui/src/entities/providers-block/model/types.ts rename to console/ui/src/entities/cluster/providers-block/model/types.ts diff --git a/console/ui/src/entities/providers-block/ui/ClusterFormCloudProviderBox.tsx b/console/ui/src/entities/cluster/providers-block/ui/ClusterFormCloudProviderBox.tsx similarity index 84% rename from console/ui/src/entities/providers-block/ui/ClusterFormCloudProviderBox.tsx rename to console/ui/src/entities/cluster/providers-block/ui/ClusterFormCloudProviderBox.tsx index eb86ba3bb2..017f0183a6 100644 --- a/console/ui/src/entities/providers-block/ui/ClusterFormCloudProviderBox.tsx +++ b/console/ui/src/entities/cluster/providers-block/ui/ClusterFormCloudProviderBox.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; import SelectableBox from '@shared/ui/selectable-box'; -import { ClusterFormCloudProviderBoxProps } from '@entities/providers-block/model/types.ts'; +import { ClusterFormCloudProviderBoxProps } from '@entities/cluster/providers-block/model/types.ts'; const ClusterFormCloudProviderBox: FC = ({ children, isActive, ...props }) => { return ( diff --git a/console/ui/src/entities/providers-block/ui/index.tsx b/console/ui/src/entities/cluster/providers-block/ui/index.tsx similarity index 77% rename from console/ui/src/entities/providers-block/ui/index.tsx rename to console/ui/src/entities/cluster/providers-block/ui/index.tsx index 8b99bc96a0..a0f7fdd273 100644 --- a/console/ui/src/entities/providers-block/ui/index.tsx +++ b/console/ui/src/entities/cluster/providers-block/ui/index.tsx @@ -3,12 +3,15 @@ import { Box, Stack, Tooltip, Typography, useTheme } from '@mui/material'; import { Controller, useFormContext } from 'react-hook-form'; import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; import { useTranslation } from 'react-i18next'; -import { useNameIconProvidersMap } from '@entities/cluster-form-cloud-region-block/lib/hooks.tsx'; +import { useNameIconProvidersMap } from '@entities/cluster/cloud-region-block/lib/hooks.tsx'; import ErrorOutlineOutlinedIcon from '@mui/icons-material/ErrorOutlineOutlined'; -import ServersIcon from '@shared/assets/serversIcon.svg?react'; -import { ProvidersBlockProps } from '@entities/providers-block/model/types.ts'; +import ServersIcon from '@assets/serversIcon.svg?react'; +import { ProvidersBlockProps } from '@entities/cluster/providers-block/model/types.ts'; import { PROVIDERS } from '@shared/config/constants.ts'; -import ClusterFormCloudProviderBox from '@entities/providers-block/ui/ClusterFormCloudProviderBox.tsx'; +import ClusterFormCloudProviderBox from '@entities/cluster/providers-block/ui/ClusterFormCloudProviderBox.tsx'; +import { INSTANCES_BLOCK_FIELD_NAMES } from '@entities/cluster/instances-block/model/const.ts'; +import { STORAGE_BLOCK_FIELDS } from '@entities/cluster/storage-block/model/const.ts'; +import { BACKUPS_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/backups-block/model/const.ts'; const ClusterFormProvidersBlock: FC = ({ providers }) => { const { t } = useTranslation('clusters'); @@ -23,12 +26,13 @@ const ClusterFormProvidersBlock: FC = ({ providers }) => { [CLUSTER_FORM_FIELD_NAMES.PROVIDER]: value, [CLUSTER_FORM_FIELD_NAMES.REGION]: value?.cloud_regions?.[0]?.code, [CLUSTER_FORM_FIELD_NAMES.REGION_CONFIG]: value?.cloud_regions?.[0]?.datacenters?.[0], - [CLUSTER_FORM_FIELD_NAMES.INSTANCE_TYPE]: 'small', + [INSTANCES_BLOCK_FIELD_NAMES.INSTANCE_TYPE]: 'small', [CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]: value?.instance_types?.small?.[0], - [CLUSTER_FORM_FIELD_NAMES.STORAGE_AMOUNT]: + [STORAGE_BLOCK_FIELDS.STORAGE_AMOUNT]: (value as any)?.volumes?.find((volume: any) => volume?.is_default)?.min_size < 100 ? 100 : (value as any)?.volumes?.find((volume: any) => volume?.is_default)?.min_size, + [BACKUPS_BLOCK_FIELD_NAMES.IS_BACKUPS_ENABLED]: value.code !== PROVIDERS.LOCAL, })); }; @@ -59,11 +63,7 @@ const ClusterFormProvidersBlock: FC = ({ providers }) => { - + {t('yourOwn')} diff --git a/console/ui/src/entities/cluster/ssh-key-block/index.ts b/console/ui/src/entities/cluster/ssh-key-block/index.ts new file mode 100644 index 0000000000..28e84d02d2 --- /dev/null +++ b/console/ui/src/entities/cluster/ssh-key-block/index.ts @@ -0,0 +1,3 @@ +import ClusterFormSshKeyBlock from '@entities/cluster/ssh-key-block/ui'; + +export default ClusterFormSshKeyBlock; diff --git a/console/ui/src/entities/cluster/ssh-key-block/model/const.ts b/console/ui/src/entities/cluster/ssh-key-block/model/const.ts new file mode 100644 index 0000000000..00a1697925 --- /dev/null +++ b/console/ui/src/entities/cluster/ssh-key-block/model/const.ts @@ -0,0 +1,3 @@ +export const SSH_KEY_BLOCK_FIELD_NAMES = Object.freeze({ + SSH_PUBLIC_KEY: 'sshPublicKey', +}); diff --git a/console/ui/src/entities/cluster/ssh-key-block/model/types.ts b/console/ui/src/entities/cluster/ssh-key-block/model/types.ts new file mode 100644 index 0000000000..7c6a896d03 --- /dev/null +++ b/console/ui/src/entities/cluster/ssh-key-block/model/types.ts @@ -0,0 +1,5 @@ +import { SSH_KEY_BLOCK_FIELD_NAMES } from '@entities/cluster/ssh-key-block/model/const.ts'; + +export interface SshKeyBlockValues { + [SSH_KEY_BLOCK_FIELD_NAMES.SSH_PUBLIC_KEY]?: string; +} diff --git a/console/ui/src/entities/ssh-key-block/ui/index.tsx b/console/ui/src/entities/cluster/ssh-key-block/ui/index.tsx similarity index 74% rename from console/ui/src/entities/ssh-key-block/ui/index.tsx rename to console/ui/src/entities/cluster/ssh-key-block/ui/index.tsx index 0944603cc2..efa94e36d0 100644 --- a/console/ui/src/entities/ssh-key-block/ui/index.tsx +++ b/console/ui/src/entities/cluster/ssh-key-block/ui/index.tsx @@ -2,7 +2,7 @@ import { FC } from 'react'; import { Box, TextField, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { Controller, useFormContext } from 'react-hook-form'; -import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; +import { SSH_KEY_BLOCK_FIELD_NAMES } from '@entities/cluster/ssh-key-block/model/const.ts'; const ClusterFormSshKeyBlock: FC = () => { const { t } = useTranslation('clusters'); @@ -18,7 +18,7 @@ const ClusterFormSshKeyBlock: FC = () => { ( { value={value as string} placeholder={t('sshKeyCloudProviderPlaceholder')} onChange={onChange} - error={!!errors[CLUSTER_FORM_FIELD_NAMES.SSH_PUBLIC_KEY]} - helperText={(errors[CLUSTER_FORM_FIELD_NAMES.SSH_PUBLIC_KEY]?.message as string) ?? ''} + error={!!errors[SSH_KEY_BLOCK_FIELD_NAMES.SSH_PUBLIC_KEY]} + helperText={(errors[SSH_KEY_BLOCK_FIELD_NAMES.SSH_PUBLIC_KEY]?.message as string) ?? ''} /> )} /> diff --git a/console/ui/src/entities/cluster/storage-block/index.ts b/console/ui/src/entities/cluster/storage-block/index.ts new file mode 100644 index 0000000000..03faf2d833 --- /dev/null +++ b/console/ui/src/entities/cluster/storage-block/index.ts @@ -0,0 +1,3 @@ +import StorageBlock from '@entities/cluster/storage-block/ui'; + +export default StorageBlock; diff --git a/console/ui/src/entities/storage-block/lib/functions.ts b/console/ui/src/entities/cluster/storage-block/lib/functions.ts similarity index 100% rename from console/ui/src/entities/storage-block/lib/functions.ts rename to console/ui/src/entities/cluster/storage-block/lib/functions.ts diff --git a/console/ui/src/entities/cluster/storage-block/model/const.ts b/console/ui/src/entities/cluster/storage-block/model/const.ts new file mode 100644 index 0000000000..b2ca71e557 --- /dev/null +++ b/console/ui/src/entities/cluster/storage-block/model/const.ts @@ -0,0 +1,14 @@ +export const STORAGE_BLOCK_FIELDS = Object.freeze({ + STORAGE_AMOUNT: 'storageAmount', + FILE_SYSTEM_TYPE: 'fileSystemType', + VOLUME_TYPE: 'volumeType', +}); + +export const fileSystemTypeOptions = Object.freeze([ + { label: 'ext4', value: 'ext4' }, + { + label: 'xfs', + value: 'xfs', + }, + { label: 'zfs', value: 'zfs' }, +]); diff --git a/console/ui/src/entities/cluster/storage-block/ui/index.tsx b/console/ui/src/entities/cluster/storage-block/ui/index.tsx new file mode 100644 index 0000000000..2d011207ec --- /dev/null +++ b/console/ui/src/entities/cluster/storage-block/ui/index.tsx @@ -0,0 +1,122 @@ +import { FC, useEffect, useState } from 'react'; +import { Box, FormControl, InputLabel, MenuItem, Select, Stack, Tooltip, Typography, useTheme } from '@mui/material'; +import ClusterSliderBox from '@shared/ui/slider-box'; +import { useTranslation } from 'react-i18next'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; +import StorageIcon from '@assets/storageIcon.svg?react'; +import { fileSystemTypeOptions, STORAGE_BLOCK_FIELDS } from '@entities/cluster/storage-block/model/const.ts'; +import { IS_EXPERT_MODE } from '@shared/model/constants.ts'; + +const StorageBlock: FC = () => { + const { t } = useTranslation(['clusters', 'shared']); + const theme = useTheme(); + const [storage, setStorage] = useState({}); // full info about selected storage + const [volumeTypes, setVolumeTypes] = useState([]); + + const { + control, + setValue, + formState: { errors }, + } = useFormContext(); + + const watchProvider = useWatch({ name: CLUSTER_FORM_FIELD_NAMES.PROVIDER }); + const watchVolume = useWatch({ name: STORAGE_BLOCK_FIELDS.VOLUME_TYPE }); + + useEffect(() => { + const volumes = watchProvider?.volumes; + setStorage(volumes?.find((volume) => volume?.is_default) ?? {}); + + setVolumeTypes( + volumes?.map((volume) => ({ + label: volume?.volume_type, + value: volume?.volume_type, + })) ?? [], + ); + + setValue( + // imperatively set a volume type when user changes provider + STORAGE_BLOCK_FIELDS.VOLUME_TYPE, + volumes?.find((volume) => volume?.is_default)?.volume_type ?? volumes?.[0]?.volume_type, + ); + }, [watchProvider]); + + useEffect(() => { + // set selected storage size sa minimum available for selected volume + const volumes = watchProvider?.volumes; + const storage = volumes?.find((volume) => volume?.volume_type === watchVolume); + setStorage(storage); + setValue(STORAGE_BLOCK_FIELDS.STORAGE_AMOUNT, storage?.min_size ?? 1); + }, [watchVolume]); + + return ( + + + {t('dataDiskStorage')} + + ( + } + error={errors[STORAGE_BLOCK_FIELDS.STORAGE_AMOUNT]} + topRightElements={ + IS_EXPERT_MODE ? ( + + {[ + { + fieldName: STORAGE_BLOCK_FIELDS.FILE_SYSTEM_TYPE, + label: t('fileSystemType'), + options: fileSystemTypeOptions, + }, + { + fieldName: STORAGE_BLOCK_FIELDS.VOLUME_TYPE, + label: t('volumeType'), + options: volumeTypes, + }, + ].map(({ fieldName, label, options }) => ( + ( + + {label} + + + )} + /> + ))} + + ) : null + } + /> + )} + /> + + ); +}; + +export default StorageBlock; diff --git a/console/ui/src/entities/cluster/vip-address-block/index.ts b/console/ui/src/entities/cluster/vip-address-block/index.ts new file mode 100644 index 0000000000..0958a100f7 --- /dev/null +++ b/console/ui/src/entities/cluster/vip-address-block/index.ts @@ -0,0 +1,3 @@ +import VipAddressBlock from '@entities/cluster/vip-address-block/ui'; + +export default VipAddressBlock; diff --git a/console/ui/src/entities/vip-address-block/ui/index.tsx b/console/ui/src/entities/cluster/vip-address-block/ui/index.tsx similarity index 100% rename from console/ui/src/entities/vip-address-block/ui/index.tsx rename to console/ui/src/entities/cluster/vip-address-block/ui/index.tsx diff --git a/console/ui/src/entities/connection-info/index.ts b/console/ui/src/entities/connection-info/index.ts deleted file mode 100644 index 880b22c065..0000000000 --- a/console/ui/src/entities/connection-info/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import ConnectionInfo from '@entities/connection-info/ui'; - -export default ConnectionInfo; diff --git a/console/ui/src/entities/database-servers-block/index.ts b/console/ui/src/entities/database-servers-block/index.ts deleted file mode 100644 index b9a1caa2e8..0000000000 --- a/console/ui/src/entities/database-servers-block/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import DatabaseServersBlock from '@entities/database-servers-block/ui'; - -export default DatabaseServersBlock; diff --git a/console/ui/src/entities/database-servers-block/model/types.ts b/console/ui/src/entities/database-servers-block/model/types.ts deleted file mode 100644 index 08dd769caa..0000000000 --- a/console/ui/src/entities/database-servers-block/model/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { UseFieldArrayRemove } from 'react-hook-form'; - -export interface DatabaseServerBlockProps { - index: number; - remove?: UseFieldArrayRemove; -} diff --git a/console/ui/src/entities/database-servers-block/ui/DatabaseServerBox.tsx b/console/ui/src/entities/database-servers-block/ui/DatabaseServerBox.tsx deleted file mode 100644 index 074907f701..0000000000 --- a/console/ui/src/entities/database-servers-block/ui/DatabaseServerBox.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { FC } from 'react'; -import { DatabaseServerBlockProps } from '@entities/database-servers-block/model/types.ts'; -import { Controller, useFormContext } from 'react-hook-form'; -import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; -import { Card, IconButton, Stack, TextField, Typography } from '@mui/material'; -import { useTranslation } from 'react-i18next'; -import CloseIcon from '@mui/icons-material/Close'; - -const DatabaseServerBox: FC = ({ index, remove }) => { - const { t } = useTranslation(['clusters', 'shared']); - const { - control, - formState: { errors }, - } = useFormContext(); - - return ( - - {remove ? ( - - - - ) : null} - - {`${t('server', { ns: 'clusters' })} ${index + 1}`} - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - - ); -}; - -export default DatabaseServerBox; diff --git a/console/ui/src/entities/load-balancers-block/index.ts b/console/ui/src/entities/load-balancers-block/index.ts deleted file mode 100644 index 3006f3b89a..0000000000 --- a/console/ui/src/entities/load-balancers-block/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import LoadBalancersBlock from '@entities/load-balancers-block/ui'; - -export default LoadBalancersBlock; diff --git a/console/ui/src/entities/load-balancers-block/ui/index.tsx b/console/ui/src/entities/load-balancers-block/ui/index.tsx deleted file mode 100644 index 5295688e0b..0000000000 --- a/console/ui/src/entities/load-balancers-block/ui/index.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { FC } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Controller, useFormContext } from 'react-hook-form'; -import { Box, Checkbox, Stack, Tooltip, Typography } from '@mui/material'; -import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; -import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; - -const LoadBalancersBlock: FC = () => { - const { t } = useTranslation('clusters'); - const { control } = useFormContext(); - - return ( - - - {t('loadBalancers')} - - - {t('haproxyLoadBalancer')} - - - - } - /> - - - ); -}; - -export default LoadBalancersBlock; diff --git a/console/ui/src/entities/postgres-version-block/index.ts b/console/ui/src/entities/postgres-version-block/index.ts deleted file mode 100644 index d11dc47b07..0000000000 --- a/console/ui/src/entities/postgres-version-block/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import PostgresVersionBox from '@entities/postgres-version-block/ui'; - -export default PostgresVersionBox; diff --git a/console/ui/src/entities/providers-block/index.ts b/console/ui/src/entities/providers-block/index.ts deleted file mode 100644 index 7c5ed31e07..0000000000 --- a/console/ui/src/entities/providers-block/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import ClusterFormProvidersBlock from '@entities/providers-block/ui'; - -export default ClusterFormProvidersBlock; diff --git a/console/ui/src/entities/settings-proxy-block/index.ts b/console/ui/src/entities/settings-proxy-block/index.ts deleted file mode 100644 index f1e0d2d025..0000000000 --- a/console/ui/src/entities/settings-proxy-block/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import SettingsProxyBlock from '@entities/settings-proxy-block/ui'; - -export default SettingsProxyBlock; diff --git a/console/ui/src/entities/settings-proxy-block/model/types.ts b/console/ui/src/entities/settings-proxy-block/model/types.ts deleted file mode 100644 index 9217a87b8a..0000000000 --- a/console/ui/src/entities/settings-proxy-block/model/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { SETTINGS_FORM_FIELDS_NAMES } from '@entities/settings-proxy-block/model/constants.ts'; - -export interface SettingsFormValues { - [SETTINGS_FORM_FIELDS_NAMES.HTTP_PROXY]: string; - [SETTINGS_FORM_FIELDS_NAMES.HTTPS_PROXY]: string; -} diff --git a/console/ui/src/entities/settings-proxy-block/ui/index.tsx b/console/ui/src/entities/settings-proxy-block/ui/index.tsx deleted file mode 100644 index 6ae6ccd741..0000000000 --- a/console/ui/src/entities/settings-proxy-block/ui/index.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; -import { Stack, TextField, Typography } from '@mui/material'; -import { useTranslation } from 'react-i18next'; -import { Controller, useFormContext } from 'react-hook-form'; -import { SETTINGS_FORM_FIELDS_NAMES } from '@entities/settings-proxy-block/model/constants.ts'; - -const SettingsProxyBlock: React.FC = () => { - const { t } = useTranslation('settings'); - - const { control } = useFormContext(); - - return ( - - - {t('proxyServer')} - - {t('proxyServerInfo')} - - ( - - http_proxy - - - )} - /> - ( - - https_proxy - - - )} - /> - - - ); -}; - -export default SettingsProxyBlock; diff --git a/console/ui/src/entities/settings/expert-mode-block/ui/index.tsx b/console/ui/src/entities/settings/expert-mode-block/ui/index.tsx new file mode 100644 index 0000000000..b70fcd247d --- /dev/null +++ b/console/ui/src/entities/settings/expert-mode-block/ui/index.tsx @@ -0,0 +1,44 @@ +import { FC } from 'react'; +import { Stack, Switch, Typography } from '@mui/material'; +import { Controller, useFormContext } from 'react-hook-form'; +import { SETTINGS_FORM_FIELDS_NAMES } from '@entities/settings/proxy-block/model/constants.ts'; +import { useTranslation } from 'react-i18next'; + +const SettingExpertModeBlock: FC = () => { + const { t } = useTranslation('settings'); + + const { control } = useFormContext(); + + return ( + + {t('expertMode')} + {t('expertModeInfo')} + {[ + { + fieldName: SETTINGS_FORM_FIELDS_NAMES.IS_EXPERT_MODE_ENABLED, + label: t('enableExpertMode'), + }, + { + fieldName: SETTINGS_FORM_FIELDS_NAMES.IS_YAML_ENABLED, + label: t('enableYamlTab'), + }, + ].map(({ fieldName, label }) => ( + ( + + + {label} + + + + )} + /> + ))} + + ); +}; + +export default SettingExpertModeBlock; diff --git a/console/ui/src/entities/settings/proxy-block/index.ts b/console/ui/src/entities/settings/proxy-block/index.ts new file mode 100644 index 0000000000..1e7a873544 --- /dev/null +++ b/console/ui/src/entities/settings/proxy-block/index.ts @@ -0,0 +1,3 @@ +import SettingsProxyBlock from '@entities/settings/proxy-block/ui'; + +export default SettingsProxyBlock; diff --git a/console/ui/src/entities/settings-proxy-block/model/constants.ts b/console/ui/src/entities/settings/proxy-block/model/constants.ts similarity index 57% rename from console/ui/src/entities/settings-proxy-block/model/constants.ts rename to console/ui/src/entities/settings/proxy-block/model/constants.ts index c87aad173a..e3b7f5dc77 100644 --- a/console/ui/src/entities/settings-proxy-block/model/constants.ts +++ b/console/ui/src/entities/settings/proxy-block/model/constants.ts @@ -1,4 +1,6 @@ export const SETTINGS_FORM_FIELDS_NAMES = Object.freeze({ HTTP_PROXY: 'http_proxy', HTTPS_PROXY: 'https_proxy', + IS_EXPERT_MODE_ENABLED: 'is_expert_mode_enabled', + IS_YAML_ENABLED: 'is_yaml_enabled', }); diff --git a/console/ui/src/entities/settings/proxy-block/model/types.ts b/console/ui/src/entities/settings/proxy-block/model/types.ts new file mode 100644 index 0000000000..d0ad805e4f --- /dev/null +++ b/console/ui/src/entities/settings/proxy-block/model/types.ts @@ -0,0 +1,8 @@ +import { SETTINGS_FORM_FIELDS_NAMES } from '@entities/settings/proxy-block/model/constants.ts'; + +export interface SettingsFormValues { + [SETTINGS_FORM_FIELDS_NAMES.HTTP_PROXY]: string; + [SETTINGS_FORM_FIELDS_NAMES.HTTPS_PROXY]: string; + [SETTINGS_FORM_FIELDS_NAMES.IS_EXPERT_MODE_ENABLED]: boolean; + [SETTINGS_FORM_FIELDS_NAMES.IS_YAML_ENABLED]: boolean; +} diff --git a/console/ui/src/entities/settings/proxy-block/ui/index.tsx b/console/ui/src/entities/settings/proxy-block/ui/index.tsx new file mode 100644 index 0000000000..5f8e4fba1d --- /dev/null +++ b/console/ui/src/entities/settings/proxy-block/ui/index.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Stack, TextField, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { Controller, useFormContext } from 'react-hook-form'; +import { SETTINGS_FORM_FIELDS_NAMES } from '@entities/settings/proxy-block/model/constants.ts'; + +const SettingsProxyBlock: React.FC = () => { + const { t } = useTranslation('settings'); + + const { control } = useFormContext(); + + return ( + + {t('proxyServer')} + {t('proxyServerInfo')} + + {[ + { + fieldName: SETTINGS_FORM_FIELDS_NAMES.HTTP_PROXY, + label: 'http_proxy', + }, + { fieldName: SETTINGS_FORM_FIELDS_NAMES.HTTPS_PROXY, label: 'https_proxy' }, + ].map(({ fieldName, label }) => ( + ( + + {label} + + + )} + /> + ))} + + + ); +}; + +export default SettingsProxyBlock; diff --git a/console/ui/src/entities/sidebar-item/model/types.ts b/console/ui/src/entities/sidebar-item/model/types.ts index f00f6ac103..114e35e3ef 100644 --- a/console/ui/src/entities/sidebar-item/model/types.ts +++ b/console/ui/src/entities/sidebar-item/model/types.ts @@ -4,7 +4,7 @@ export interface SidebarItemProps { path: string; label: string; icon?: ComponentType>; - isActive?: string; + isActive?: boolean; isCollapsed?: boolean; target?: string; } diff --git a/console/ui/src/entities/sidebar-item/ui/SidebarItemContent.tsx b/console/ui/src/entities/sidebar-item/ui/SidebarItemContent.tsx index bb225fd63b..ec51ef3369 100644 --- a/console/ui/src/entities/sidebar-item/ui/SidebarItemContent.tsx +++ b/console/ui/src/entities/sidebar-item/ui/SidebarItemContent.tsx @@ -12,7 +12,7 @@ const SidebarItemContent: FC = ({ isCollapsed, }) => { const theme = useTheme(); - + return ( = ({ {SidebarIcon ? : null} {!isCollapsed ? ( - { - const { t } = useTranslation('clusters'); - const theme = useTheme(); - - const { - control, - watch, - formState: { errors }, - } = useFormContext(); - - const watchProvider = watch(CLUSTER_FORM_FIELD_NAMES.PROVIDER); - - const storage = watchProvider?.volumes?.find((volume) => volume?.is_default) ?? {}; - - return ( - - - {t('dataDiskStorage')} - - ( - } - error={errors[CLUSTER_FORM_FIELD_NAMES.STORAGE_AMOUNT]} - /> - )} - /> - - ); -}; - -export default StorageBlock; diff --git a/console/ui/src/entities/vip-address-block/index.ts b/console/ui/src/entities/vip-address-block/index.ts deleted file mode 100644 index ac25bc850a..0000000000 --- a/console/ui/src/entities/vip-address-block/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import VipAddressBlock from '@entities/vip-address-block/ui'; - -export default VipAddressBlock; diff --git a/console/ui/src/features/cluster-secret-modal/lib/functions.ts b/console/ui/src/features/cluster-secret-modal/lib/functions.ts deleted file mode 100644 index 9b3fca7866..0000000000 --- a/console/ui/src/features/cluster-secret-modal/lib/functions.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { RequestClusterCreate } from '@shared/api/api/clusters.ts'; -import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; -import { PROVIDER_CODE_TO_ANSIBLE_USER_MAP } from '@features/cluster-secret-modal/model/constants.ts'; -import { AUTHENTICATION_METHODS } from '@shared/model/constants.ts'; -import { PROVIDERS } from '@shared/config/constants.ts'; -import { ClusterFormValues } from '@features/cluster-secret-modal/model/types.ts'; - -import { - SECRET_MODAL_CONTENT_BODY_FORM_FIELDS, - SECRET_MODAL_CONTENT_FORM_FIELD_NAMES, -} from '@entities/secret-form-block/model/constants.ts'; - -export const getCommonExtraVars = (values: ClusterFormValues) => ({ - postgresql_version: values[CLUSTER_FORM_FIELD_NAMES.POSTGRES_VERSION], - patroni_cluster_name: values[CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME], -}); - -export const getCloudProviderExtraVars = (values: ClusterFormValues) => ({ - cloud_provider: values[CLUSTER_FORM_FIELD_NAMES.PROVIDER].code, - server_type: values[CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG].code, - server_location: values[CLUSTER_FORM_FIELD_NAMES.REGION_CONFIG].code, - server_count: values[CLUSTER_FORM_FIELD_NAMES.INSTANCES_AMOUNT], - volume_size: values[CLUSTER_FORM_FIELD_NAMES.STORAGE_AMOUNT], - ssh_public_keys: values[CLUSTER_FORM_FIELD_NAMES.SSH_PUBLIC_KEY].split('\n').map((key) => `'${key}'`), - ansible_user: PROVIDER_CODE_TO_ANSIBLE_USER_MAP[values[CLUSTER_FORM_FIELD_NAMES.PROVIDER].code], - ...getCommonExtraVars(values), - ...values[CLUSTER_FORM_FIELD_NAMES.REGION_CONFIG].cloud_image.image, -}); - -export const getLocalMachineExtraVars = (values: ClusterFormValues, secretId?: number) => ({ - ...(values[CLUSTER_FORM_FIELD_NAMES.CLUSTER_VIP_ADDRESS] - ? { cluster_vip: values[CLUSTER_FORM_FIELD_NAMES.CLUSTER_VIP_ADDRESS] } - : {}), - ...(values[CLUSTER_FORM_FIELD_NAMES.IS_HAPROXY_LOAD_BALANCER] ? { with_haproxy_load_balancing: true } : {}), - ...(!secretId && - !values[CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET] && - values[CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD] === AUTHENTICATION_METHODS.PASSWORD - ? { - ansible_user: values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.USERNAME], - ansible_ssh_pass: values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.PASSWORD], - } - : {}), - ...getCommonExtraVars(values), -}); - -export const getLocalMachineEnvs = (values: ClusterFormValues, secretId?: number) => ({ - ...(values[CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD] === AUTHENTICATION_METHODS.SSH && - !values[CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET] && - !secretId - ? { - SSH_PRIVATE_KEY_CONTENT: btoa(values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SSH_PRIVATE_KEY]), - } - : {}), - ANSIBLE_INVENTORY_JSON: btoa( - JSON.stringify({ - all: { - vars: { - ansible_user: values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.USERNAME], - ...(values[CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD] === AUTHENTICATION_METHODS.PASSWORD - ? { - ansible_ssh_pass: values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.USERNAME], - ansible_sudo_pass: values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.PASSWORD], - } - : {}), - }, - children: { - balancers: { - hosts: values[CLUSTER_FORM_FIELD_NAMES.IS_HAPROXY_LOAD_BALANCER] - ? values[CLUSTER_FORM_FIELD_NAMES.DATABASE_SERVERS].reduce( - (acc, server) => ({ - ...acc, - [server[CLUSTER_FORM_FIELD_NAMES.IP_ADDRESS]]: { - ansible_host: server[CLUSTER_FORM_FIELD_NAMES.IP_ADDRESS], - }, - }), - {}, - ) - : {}, - }, - consul_instances: { - hosts: {}, - }, - etcd_cluster: { - hosts: values[CLUSTER_FORM_FIELD_NAMES.DATABASE_SERVERS].reduce( - (acc, server) => ({ - ...acc, - [server[CLUSTER_FORM_FIELD_NAMES.IP_ADDRESS]]: { - ansible_host: server[CLUSTER_FORM_FIELD_NAMES.IP_ADDRESS], - }, - }), - {}, - ), - }, - master: { - hosts: { - [values[CLUSTER_FORM_FIELD_NAMES.DATABASE_SERVERS][0][CLUSTER_FORM_FIELD_NAMES.IP_ADDRESS]]: { - hostname: values[CLUSTER_FORM_FIELD_NAMES.DATABASE_SERVERS][0][CLUSTER_FORM_FIELD_NAMES.HOSTNAME], - ansible_host: values[CLUSTER_FORM_FIELD_NAMES.DATABASE_SERVERS][0][CLUSTER_FORM_FIELD_NAMES.IP_ADDRESS], - server_location: - values[CLUSTER_FORM_FIELD_NAMES.DATABASE_SERVERS]?.[0]?.[CLUSTER_FORM_FIELD_NAMES.LOCATION], - postgresql_exists: values[CLUSTER_FORM_FIELD_NAMES.EXISTING_CLUSTER] ?? false, - }, - }, - }, - ...(values[CLUSTER_FORM_FIELD_NAMES.DATABASE_SERVERS].length > 1 - ? { - replica: { - hosts: values[CLUSTER_FORM_FIELD_NAMES.DATABASE_SERVERS].slice(1).reduce( - (acc, server) => ({ - ...acc, - [server.ipAddress]: { - hostname: server?.[CLUSTER_FORM_FIELD_NAMES.HOSTNAME], - ansible_host: server?.[CLUSTER_FORM_FIELD_NAMES.IP_ADDRESS], - server_location: server?.[CLUSTER_FORM_FIELD_NAMES.LOCATION], - postgresql_exists: values[CLUSTER_FORM_FIELD_NAMES.EXISTING_CLUSTER] ?? false, - }, - }), - {}, - ), - }, - } - : {}), - postgres_cluster: { - children: { - master: {}, - replica: {}, - }, - }, - }, - }, - }), - ), -}); - -const convertObjectToRequiredFormat = (object: Record) => { - return Object.entries(object).reduce((acc: string[], [key, value]) => [...acc, `${key}=${value}`], []); -}; - -export const mapFormValuesToRequestFields = ({ - values, - secretId, - projectId, - envs, -}: { - values: ClusterFormValues; - secretId?: number; - projectId: number; - envs?: object; -}): RequestClusterCreate => ({ - project_id: projectId, - name: values[CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME], - environment_id: values[CLUSTER_FORM_FIELD_NAMES.ENVIRONMENT_ID], - description: values[CLUSTER_FORM_FIELD_NAMES.DESCRIPTION], - ...(secretId ? { auth_info: { secret_id: secretId } } : {}), - ...(values[CLUSTER_FORM_FIELD_NAMES.PROVIDER].code === PROVIDERS.LOCAL - ? { envs: convertObjectToRequiredFormat(getLocalMachineEnvs(values, secretId)) } - : envs && values[CLUSTER_FORM_FIELD_NAMES.PROVIDER].code !== PROVIDERS.LOCAL - ? { - envs: convertObjectToRequiredFormat( - Object.fromEntries(Object.entries(envs).filter(([key]) => SECRET_MODAL_CONTENT_BODY_FORM_FIELDS?.[key])), - ), - } - : {}), - extra_vars: convertObjectToRequiredFormat( - values[CLUSTER_FORM_FIELD_NAMES.PROVIDER].code === PROVIDERS.LOCAL - ? getLocalMachineExtraVars(values, secretId) - : getCloudProviderExtraVars(values), - ), - existing_cluster: values[CLUSTER_FORM_FIELD_NAMES.EXISTING_CLUSTER] ?? false, -}); diff --git a/console/ui/src/features/cluster-secret-modal/model/types.ts b/console/ui/src/features/cluster-secret-modal/model/types.ts index 48f055a988..1edbe7234f 100644 --- a/console/ui/src/features/cluster-secret-modal/model/types.ts +++ b/console/ui/src/features/cluster-secret-modal/model/types.ts @@ -6,9 +6,22 @@ import { ResponseDeploymentInfo, } from '@shared/api/api/deployments.ts'; import { AUTHENTICATION_METHODS } from '@shared/model/constants.ts'; -import { ClusterDatabaseServer } from '@widgets/cluster-form/model/types.ts'; import { SecretFormValues } from '@entities/secret-form-block/model/types.ts'; import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; +import { BackupsBlockValues } from '@entities/cluster/expert-mode/backups-block/model/types.ts'; +import { ExtensionsBlockValues } from '@entities/cluster/expert-mode/extensions-block/model/types.ts'; +import { DatabasesBlockValues } from '@entities/cluster/expert-mode/databases-block/model/types.ts'; +import { INSTANCES_BLOCK_FIELD_NAMES } from '@entities/cluster/instances-block/model/const.ts'; +import { STORAGE_BLOCK_FIELDS } from '@entities/cluster/storage-block/model/const.ts'; +import { SshKeyBlockValues } from '@entities/cluster/ssh-key-block/model/types.ts'; +import { DcsBlockFormValues } from '@entities/cluster/expert-mode/dcs-block/model/types.ts'; +import { DatabaseServerBlockValues } from '@entities/cluster/database-servers-block/model/types.ts'; +import { DataDirectoryFormValues } from '@entities/cluster/expert-mode/data-directory-block/model/types.ts'; +import { LoadBalancersBlockValues } from '@entities/cluster/load-balancers-block/model/types.ts'; +import { ConnectionPoolBlockValues } from '@entities/cluster/expert-mode/connection-pools-block/model/types.ts'; +import { AdditionalSettingsBlockValues } from '@entities/cluster/expert-mode/additional-settings-block/model/types.ts'; +import { PostgresParametersBlockValues } from '@entities/cluster/expert-mode/postgres-parameters-block/model/types.ts'; +import { KernelParametersBlockValues } from '@entities/cluster/expert-mode/kernel-parameters-block/model/types.ts'; export interface ClusterSecretModalProps { isClusterFormSubmitting?: boolean; @@ -19,32 +32,45 @@ export interface ClusterSecretModalFormValues extends SecretFormValues { [CLUSTER_SECRET_MODAL_FORM_FIELD_NAMES.IS_SAVE_TO_CONSOLE]: boolean; } -interface ClusterCloudProviderFormValues { +interface ClusterCloudProviderFormValues extends BackupsBlockValues, SshKeyBlockValues { [CLUSTER_FORM_FIELD_NAMES.REGION]?: string; [CLUSTER_FORM_FIELD_NAMES.REGION_CONFIG]?: DeploymentInfoCloudRegion; - [CLUSTER_FORM_FIELD_NAMES.INSTANCE_TYPE]?: ['small', 'medium', 'large']; + [INSTANCES_BLOCK_FIELD_NAMES.INSTANCE_TYPE]?: 'small' | 'medium' | 'large' | 'custom'; [CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]?: DeploymentInstanceType; [CLUSTER_FORM_FIELD_NAMES.INSTANCES_AMOUNT]?: number; - [CLUSTER_FORM_FIELD_NAMES.STORAGE_AMOUNT]?: number; - [CLUSTER_FORM_FIELD_NAMES.SSH_PUBLIC_KEY]?: string; + [STORAGE_BLOCK_FIELDS.STORAGE_AMOUNT]?: number; + [CLUSTER_FORM_FIELD_NAMES.IS_SPOT_INSTANCES]?: boolean; + [CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET]?: boolean; } interface ClusterLocalMachineProviderFormValues extends Pick< - SECRET_MODAL_CONTENT_FORM_FIELD_NAMES, - | SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.USERNAME - | SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.PASSWORD - | SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.PRIVATE_KEY - > { - [CLUSTER_FORM_FIELD_NAMES.DATABASE_SERVERS]?: ClusterDatabaseServer[]; - [CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD]?: typeof AUTHENTICATION_METHODS; + typeof SECRET_MODAL_CONTENT_FORM_FIELD_NAMES, + | [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.USERNAME] + | [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.PASSWORD] + | [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SSH_PRIVATE_KEY] + >, + LoadBalancersBlockValues, + DatabaseServerBlockValues, + DcsBlockFormValues { + [CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD]?: (typeof AUTHENTICATION_METHODS)[keyof typeof AUTHENTICATION_METHODS]; [CLUSTER_FORM_FIELD_NAMES.SECRET_KEY_NAME]?: string; [CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_IS_SAVE_TO_CONSOLE]?: boolean; [CLUSTER_FORM_FIELD_NAMES.CLUSTER_VIP_ADDRESS]?: string; - [CLUSTER_FORM_FIELD_NAMES.IS_HAPROXY_LOAD_BALANCER]?: boolean; } -export interface ClusterFormValues extends ClusterCloudProviderFormValues, ClusterLocalMachineProviderFormValues { +export interface ClusterFormValues + extends ClusterCloudProviderFormValues, + ClusterLocalMachineProviderFormValues, + SecretFormValues, + ExtensionsBlockValues, + DatabasesBlockValues, + DataDirectoryFormValues, + ConnectionPoolBlockValues, + AdditionalSettingsBlockValues, + PostgresParametersBlockValues, + KernelParametersBlockValues { + [CLUSTER_FORM_FIELD_NAMES.CREATION_TYPE]: string; [CLUSTER_FORM_FIELD_NAMES.PROVIDER]: ResponseDeploymentInfo; [CLUSTER_FORM_FIELD_NAMES.ENVIRONMENT_ID]: number; [CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME]: string; diff --git a/console/ui/src/features/cluster-secret-modal/ui/index.tsx b/console/ui/src/features/cluster-secret-modal/ui/index.tsx index 610dc90a1d..21fd88e0ef 100644 --- a/console/ui/src/features/cluster-secret-modal/ui/index.tsx +++ b/console/ui/src/features/cluster-secret-modal/ui/index.tsx @@ -17,18 +17,23 @@ import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants. import { generateAbsoluteRouterPath, handleRequestErrorCatch } from '@shared/lib/functions.ts'; import RouterPaths from '@app/router/routerPathsConfig'; import { useNavigate } from 'react-router-dom'; -import { ClusterSecretModalFormValues, ClusterSecretModalProps } from '@features/cluster-secret-modal/model/types.ts'; +import { + ClusterFormValues, + ClusterSecretModalFormValues, + ClusterSecretModalProps, +} from '@features/cluster-secret-modal/model/types.ts'; import { useGetSecretsQuery, usePostSecretsMutation } from '@shared/api/api/secrets.ts'; import { CLUSTER_SECRET_MODAL_FORM_FIELD_NAMES } from '@features/cluster-secret-modal/model/constants.ts'; import { useAppSelector } from '@app/redux/store/hooks.ts'; import { selectCurrentProject } from '@app/redux/slices/projectSlice/projectSelectors.ts'; import { toast } from 'react-toastify'; -import { mapFormValuesToRequestFields } from '@features/cluster-secret-modal/lib/functions.ts'; import { usePostClustersMutation } from '@shared/api/api/clusters.ts'; import SecretFormBlock from '@entities/secret-form-block'; import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; import { getSecretBodyFromValues } from '@entities/secret-form-block/lib/functions.ts'; +import { DATABASE_SERVERS_FIELD_NAMES } from '@entities/cluster/database-servers-block/model/const.ts'; +import { mapFormValuesToRequestFields } from '@shared/lib/clusterValuesTransformFunctions.ts'; const ClusterSecretModal: FC = ({ isClusterFormDisabled = false }) => { const { t } = useTranslation(['clusters', 'shared', 'toasts']); @@ -56,17 +61,17 @@ const ClusterSecretModal: FC = ({ isClusterFormDisabled const cancelHandler = () => navigate(generateAbsoluteRouterPath(RouterPaths.clusters.absolutePath)); - const onSubmit = async (values: ClusterSecretModalFormValues) => { + const onSubmit = async (secretsFields: ClusterSecretModalFormValues) => { const clusterFormValues = getValues(); try { - if (values[CLUSTER_SECRET_MODAL_FORM_FIELD_NAMES.IS_SAVE_TO_CONSOLE] && !createSecretResultRef?.current) { + if (secretsFields[CLUSTER_SECRET_MODAL_FORM_FIELD_NAMES.IS_SAVE_TO_CONSOLE] && !createSecretResultRef?.current) { createSecretResultRef.current = await addSecretTrigger({ requestSecretCreate: { project_id: Number(currentProject), type: clusterFormValues[CLUSTER_FORM_FIELD_NAMES.PROVIDER].code, - name: values[CLUSTER_SECRET_MODAL_FORM_FIELD_NAMES.SECRET_NAME], + name: secretsFields[CLUSTER_SECRET_MODAL_FORM_FIELD_NAMES.SECRET_NAME], value: getSecretBodyFromValues({ - ...values, + ...secretsFields, [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SECRET_TYPE]: clusterFormValues.provider.code, }), }, @@ -74,37 +79,37 @@ const ClusterSecretModal: FC = ({ isClusterFormDisabled toast.success( t('secretSuccessfullyCreated', { ns: 'toasts', - secretName: values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SECRET_NAME], + secretName: secretsFields[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SECRET_NAME], }), ); } if (!secrets.data?.data?.length && !createSecretResultRef?.current?.id) { await addClusterTrigger({ requestClusterCreate: mapFormValuesToRequestFields({ - values: clusterFormValues, - envs: values, + values: clusterFormValues as ClusterFormValues, + secretsInfo: secretsFields, projectId: Number(currentProject), }), }).unwrap(); } else { await addClusterTrigger({ requestClusterCreate: mapFormValuesToRequestFields({ - values: clusterFormValues, - secretId: createSecretResultRef.current?.id ?? values[CLUSTER_FORM_FIELD_NAMES.SECRET_ID], + values: clusterFormValues as ClusterFormValues, + secretId: createSecretResultRef.current?.id ?? secretsFields[CLUSTER_FORM_FIELD_NAMES.SECRET_ID], projectId: Number(currentProject), }), }).unwrap(); } toast.success( t( - clusterFormValues[CLUSTER_FORM_FIELD_NAMES.EXISTING_CLUSTER] + clusterFormValues[DATABASE_SERVERS_FIELD_NAMES.IS_CLUSTER_EXISTS] ? 'clusterSuccessfullyImported' : 'clusterSuccessfullyCreated', { ns: 'toasts', clusterName: clusterFormValues[CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME], - } - ) + }, + ), ); navigate(generateAbsoluteRouterPath(RouterPaths.clusters.absolutePath)); } catch (e) { @@ -119,11 +124,16 @@ const ClusterSecretModal: FC = ({ isClusterFormDisabled return ( @@ -200,12 +210,21 @@ const ClusterSecretModal: FC = ({ isClusterFormDisabled )} diff --git a/console/ui/src/features/clusters-table-row-actions/ui/ClusterTableExportButton.tsx b/console/ui/src/features/clusters-table-row-actions/ui/ClusterTableExportButton.tsx index 1268157cab..5adc6736e1 100644 --- a/console/ui/src/features/clusters-table-row-actions/ui/ClusterTableExportButton.tsx +++ b/console/ui/src/features/clusters-table-row-actions/ui/ClusterTableExportButton.tsx @@ -16,12 +16,12 @@ const ClustersTableExportButton: FC = ({ cluster // Handle boolean values if (valueRaw === 'true') return true; if (valueRaw === 'false') return false; - + // Handle numeric values if (!isNaN(Number(valueRaw)) && valueRaw !== '') { return Number(valueRaw); } - + // Handle JSON structures (objects and arrays) if (valueRaw.startsWith('{') || valueRaw.startsWith('[')) { try { @@ -31,7 +31,7 @@ const ClustersTableExportButton: FC = ({ cluster // If JSON parsing fails, try to fix common issues try { let fixedValue = valueRaw; - + if (valueRaw.startsWith('{')) { // Fix object notation: {key:value} -> {"key":"value"} // Handle unquoted keys and values @@ -58,7 +58,7 @@ const ClustersTableExportButton: FC = ({ cluster }); }); } - + return JSON.parse(fixedValue); } catch (e2) { // If all parsing attempts fail, return as string @@ -66,7 +66,7 @@ const ClustersTableExportButton: FC = ({ cluster } } } - + // Return as string if no special processing needed return valueRaw; }; @@ -194,4 +194,4 @@ const ClustersTableExportButton: FC = ({ cluster ); }; -export default ClustersTableExportButton; \ No newline at end of file +export default ClustersTableExportButton; diff --git a/console/ui/src/pages/add-cluster/ui/index.tsx b/console/ui/src/pages/add-cluster/ui/index.tsx index 102b3724a3..3c79c4489f 100644 --- a/console/ui/src/pages/add-cluster/ui/index.tsx +++ b/console/ui/src/pages/add-cluster/ui/index.tsx @@ -1,8 +1,117 @@ -import { FC } from 'react'; +import { FC, useEffect, useState } from 'react'; import ClusterForm from '@widgets/cluster-form'; +import ClusterSummary from '@widgets/cluster-summary'; +import { Box, Divider, Stack, Tab } from '@mui/material'; +import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; +import { ClusterFormValues } from '@features/cluster-secret-modal/model/types.ts'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { ClusterFormSchema } from '@widgets/cluster-form/model/validation.ts'; +import { + CLUSTER_CREATION_TYPES, + CLUSTER_FORM_DEFAULT_VALUES, + CLUSTER_FORM_FIELD_NAMES, +} from '@widgets/cluster-form/model/constants.ts'; +import { useTranslation } from 'react-i18next'; +import { useGetExternalDeploymentsQuery } from '@shared/api/api/deployments.ts'; +import { useGetEnvironmentsQuery } from '@shared/api/api/environments.ts'; +import { useGetPostgresVersionsQuery } from '@shared/api/api/other.ts'; +import { useGetClustersDefaultNameQuery } from '@shared/api/api/clusters.ts'; +import Spinner from '@shared/ui/spinner'; +import { STORAGE_BLOCK_FIELDS } from '@entities/cluster/storage-block/model/const.ts'; +import { IS_EXPERT_MODE, IS_YAML_ENABLED } from '@shared/model/constants.ts'; +import YamlEditorForm from '@widgets/yaml-editor-form/ui'; +import { TabContext, TabList, TabPanel } from '@mui/lab'; const AddCluster: FC = () => { - return ; + const { t } = useTranslation(['clusters', 'validation', 'toasts']); + const [isResetting, setIsResetting] = useState(false); + + const methods = useForm({ + mode: 'all', + resolver: yupResolver(ClusterFormSchema(t)), + defaultValues: CLUSTER_FORM_DEFAULT_VALUES, + }); + + const deployments = useGetExternalDeploymentsQuery({ offset: 0, limit: 999_999_999 }); + const environments = useGetEnvironmentsQuery({ offset: 0, limit: 999_999_999 }); + const postgresVersions = useGetPostgresVersionsQuery(); + const clusterName = useGetClustersDefaultNameQuery(); + + const watchClusterCreationType = useWatch({ name: CLUSTER_FORM_FIELD_NAMES.CREATION_TYPE, control: methods.control }); + + useEffect(() => { + if (deployments.data?.data && postgresVersions.data?.data && environments.data?.data && clusterName.data) { + setIsResetting(true); + // eslint-disable-next-line @typescript-eslint/require-await + const resetForm = async () => { + // sync function will result in form values setting error + const providers = deployments.data?.data; + methods.reset((values) => ({ + ...values, + [CLUSTER_FORM_FIELD_NAMES.PROVIDER]: providers?.[0], + [CLUSTER_FORM_FIELD_NAMES.REGION]: providers?.[0]?.cloud_regions?.[0]?.code, + [CLUSTER_FORM_FIELD_NAMES.REGION_CONFIG]: providers?.[0]?.cloud_regions?.[0]?.datacenters?.[0], + [CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]: providers?.[0]?.instance_types?.small?.[0], + [CLUSTER_FORM_FIELD_NAMES.POSTGRES_VERSION]: postgresVersions.data?.data?.at(-1)?.major_version, + [CLUSTER_FORM_FIELD_NAMES.ENVIRONMENT_ID]: environments.data?.data?.[0]?.id, + [CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME]: clusterName.data?.name ?? 'postgres-cluster', + ...(IS_EXPERT_MODE + ? { + [STORAGE_BLOCK_FIELDS.VOLUME_TYPE]: + providers?.[0]?.volumes?.find((volume) => volume?.is_default)?.volume_type ?? + providers?.[0]?.volumes?.[0]?.volume_type, + } + : {}), + })); + }; + void resetForm().then(() => setIsResetting(false)); + } + }, [deployments.data?.data, postgresVersions.data?.data, environments.data?.data, clusterName.data]); + + const handleTabChange = (onChange: (...event: any[]) => void) => (_, value: string) => { + onChange(value); + }; + + const clustersForm = ( + + + + + + + ); + + return ( + + {isResetting || deployments.isFetching || postgresVersions.isFetching || environments.isFetching ? ( + + ) : IS_EXPERT_MODE && IS_YAML_ENABLED ? ( + + ( + + {Object.values(CLUSTER_CREATION_TYPES)?.map((value) => ( + + ))} + + )} + /> + + {clustersForm} + + + + + ) : ( + clustersForm + )} + + ); }; export default AddCluster; diff --git a/console/ui/src/pages/overview-cluster/ui/index.tsx b/console/ui/src/pages/overview-cluster/ui/index.tsx index ff21dc7157..380a1df598 100644 --- a/console/ui/src/pages/overview-cluster/ui/index.tsx +++ b/console/ui/src/pages/overview-cluster/ui/index.tsx @@ -4,8 +4,8 @@ import { useParams } from 'react-router-dom'; import { useGetClustersByIdQuery } from '@shared/api/api/clusters.ts'; import { Grid } from '@mui/material'; import ClusterOverviewTable from '@widgets/cluster-overview-table'; -import ConnectionInfo from '@entities/connection-info'; -import ClusterInfo from '@entities/cluster-info'; +import ConnectionInfo from '@entities/cluster/connection-info'; +import ClusterInfo from '@entities/cluster/cluster-info'; import { useQueryPolling } from '@shared/lib/hooks.tsx'; import { CLUSTER_OVERVIEW_POLLING_INTERVAL } from '@shared/config/constants.ts'; import Spinner from '@shared/ui/spinner'; diff --git a/console/ui/src/shared/api/api/clusters.ts b/console/ui/src/shared/api/api/clusters.ts index 08b50f311e..e540a5bac5 100644 --- a/console/ui/src/shared/api/api/clusters.ts +++ b/console/ui/src/shared/api/api/clusters.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ + import { baseApi as api } from '../baseApi.ts'; const injectedRtkApi = api.injectEndpoints({ diff --git a/console/ui/src/shared/i18n/locales/en/clusters.json b/console/ui/src/shared/i18n/locales/en/clusters.json index 9566c1c0cb..c395737781 100644 --- a/console/ui/src/shared/i18n/locales/en/clusters.json +++ b/console/ui/src/shared/i18n/locales/en/clusters.json @@ -64,10 +64,72 @@ "ipAddress": "IP Address", "port": "Port", "backups": "Backups", + "backupsEnabled": "Backups enabled", "useDefinedSecret": "Use defined secret?", "deleteClusterModalHeader": "Delete {{clusterName}}?", "deleteClusterModalBody": "Are you sure you want to delete cluster \"{{clusterName}}\"?", "sharedVcpu": "Shared vCPU", "clusterExistsLabel": "Cluster exists", - "clusterExistsHelp": "Use this option if you want to import an existing cluster." + "clusterExistsHelp": "Use this option if you want to import an existing cluster.", + "spotInstances": "Spot instances", + "spotInstancesTooltip": "Spot instances are offered at a substantial discount, typically 60-90% cheaper than standard instances. While ideal for testing due to their lower cost, they are not suitable for production deployments as they can be terminated at any time when the cloud provider needs to reclaim capacity.", + "fileSystemType": "File system type", + "volumeType": "Volume type", + "backupMethod": "Backup method", + "backupStartTime": "Backup start time", + "backupRetention": "Backup retention (days)", + "backupRetentionTooltip": "The number of days to retain backup files. Backups older than this period will be automatically deleted.", + "configure": "Configure", + "configureBackup": "Configure backup", + "global": "Global", + "postgresParameters": "Postgres parameters", + "postgresParametersInfo": "These Postgres settings will be applied when the cluster is created.", + "configurePostgresParameters": "Configure postgres parameters", + "kernelParameters": "Kernel parameters", + "kernelParametersInfo": "These kernel settings will be applied when the cluster is created.", + "configureKernelParameters": "Configure kernel parameters", + "specifyIfQualified": "Specify the values explicitly only if you know exactly what you want to do.", + "additionalSettings": "Additional settings", + "syncStandbyNodes": "Synchronous standby nodes", + "syncStandbyNodesTooltip": "Enable synchronous database replication. This option sets the number of synchronous standby nodes.", + "syncModeStrict": "Synchronous mode strict", + "syncModeStrictTooltip": "If enabled, this setting blocks all write operations to the primary database when a synchronous replica is unavailable.", + "dbPublicAccess": "Database public access", + "dbPublicAccessTooltip": "Enables public network access to the database. This setting is not recommended for production environments due to increased security risks.", + "cloudLoadBalancer": "Cloud Load Balancer", + "cloudLoadBalancerTooltip": "Create a Load Balancer to serve as the single entry point for database connectivity within a cluster.", + "netdataMonitoring": "<0>Netdata monitoring", + "database": "Database", + "databases": "Databases", + "databaseName": "Database name", + "encoding": "Encoding", + "pool": "Pool", + "poolName": "Pool name", + "poolSize": "Pool size", + "poolMode": "Pool mode", + "connectionPooler": "Connection pooler", + "connectionPools": "Connection pools", + "extensions": "Extensions", + "showEnabled": "Show enabled", + "noExtensionsFound": "No extensions found", + "somethingWentWrongWhileRenderingExtensionsSwiper": "Something went wrong while rendering extensions swiper", + "network": "Network", + "networkInfo": "If provided, the server will be added to this network (needs to be created beforehand).", + "serverNetwork": "Server network", + "serverType": "Server type", + "dcsType": "DCS type", + "deployNewDcsCluster": "Deploy a new DCS cluster", + "deployNewDcsClusterTooltip": "Uncheck this option if you don’t want to deploy a new DCS cluster. Use it to connect to an existing DCS instead.", + "deployToDbServers": "Deploy to database servers", + "deployToDbServersTooltip": "Deploy the DCS cluster on database servers. Uncheck to use dedicated servers instead.", + "dataDirectory": "Data directory", + "dataDirectoryPlaceholder": "Enter data directory", + "deployToDatabaseServers": "Deploy to database servers", + "deployToDatabaseServersTooltip": "Select this option to deploy the HAProxy on the database servers. Uncheck if you prefer using dedicated servers for the HAProxy load balancer.", + "isPostgresqlExists": "Postgres exists", + "isPostgresqlExistsTooltip": "If PostgreSQL is already installed and running, this option lets you transform your existing setup into a fully functional high-availability cluster.", + "custom": "Custom", + "customInstanceTypeInfo": "If the required instance type is not listed, you can specify it here (ensure it follows the format supported by the API).", + "accessKey": "Access key", + "secretKey": "Secret key" } diff --git a/console/ui/src/shared/i18n/locales/en/settings.json b/console/ui/src/shared/i18n/locales/en/settings.json index 3158a47a84..c07bb7a2a5 100644 --- a/console/ui/src/shared/i18n/locales/en/settings.json +++ b/console/ui/src/shared/i18n/locales/en/settings.json @@ -23,5 +23,9 @@ "settingsPasswordSecretInfo": "Enter the SSH username and password below to access the cluster servers. It is assumed that the user account, such as root or one with sudo privileges, has already been created on the servers.", "settingsConfidentialDataStore": "All confidential data entered in these fields is stored in encrypted form.", "sshPrivateKey": "SSH private key", - "month": "Month" -} \ No newline at end of file + "month": "Month", + "expertMode": "Expert mode", + "expertModeInfo": "Activate Expert Mode to unlock advanced user interface options tailored for experience users.\nThis mode unlocks advanced settings for fine-tuning clusters, including options hidden in standard mode.", + "enableExpertMode": "Enable expert mode", + "enableYamlTab": "Enable YAML tab" +} diff --git a/console/ui/src/shared/i18n/locales/en/shared.json b/console/ui/src/shared/i18n/locales/en/shared.json index d4b9a7ab03..ffa493d5ea 100644 --- a/console/ui/src/shared/i18n/locales/en/shared.json +++ b/console/ui/src/shared/i18n/locales/en/shared.json @@ -20,6 +20,7 @@ "user": "User", "username": "Username", "password": "Password", + "userPassword": "User password", "project": "Project", "on": "On", "off": "Off", @@ -51,5 +52,10 @@ }, "switchToLightMode": "Switch to Light Mode", "switchToDarkMode": "Switch to Dark Mode", - "switchToSystemMode": "Switch to System Mode" -} \ No newline at end of file + "switchToSystemMode": "Switch to System Mode", + "valid": "Valid", + "invalid": "Invalid", + "locale": "Locale", + "somethingWentWrongWhileRendering": "Something went wrong while rendering", + "none": "None" +} diff --git a/console/ui/src/shared/i18n/locales/en/validation.json b/console/ui/src/shared/i18n/locales/en/validation.json index 48e06eb720..1ee1ce1ca2 100644 --- a/console/ui/src/shared/i18n/locales/en/validation.json +++ b/console/ui/src/shared/i18n/locales/en/validation.json @@ -1,5 +1,7 @@ { "requiredField": "Required field", + "onlyNumbers": "Values should be only numbers", "clusterShouldHaveProperNaming": "Cluster name should have only letters, numbers, hyphens and have length equal or less than 24", - "shouldBeACorrectV4Ip": "The value should be a valid IPv4 address" + "shouldBeACorrectV4Ip": "The value should be a valid IPv4 address", + "configFormat": "Value should have \"key:value\" or \"key=value\" format" } diff --git a/console/ui/src/shared/lib/clusterValuesTransformFunctions.ts b/console/ui/src/shared/lib/clusterValuesTransformFunctions.ts new file mode 100644 index 0000000000..d3457d68d9 --- /dev/null +++ b/console/ui/src/shared/lib/clusterValuesTransformFunctions.ts @@ -0,0 +1,546 @@ +import { ClusterFormValues } from '@features/cluster-secret-modal/model/types.ts'; +import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; +import { INSTANCES_BLOCK_FIELD_NAMES } from '@entities/cluster/instances-block/model/const.ts'; +import { STORAGE_BLOCK_FIELDS } from '@entities/cluster/storage-block/model/const.ts'; +import { PROVIDER_CODE_TO_ANSIBLE_USER_MAP } from '@features/cluster-secret-modal/model/constants.ts'; +import { SSH_KEY_BLOCK_FIELD_NAMES } from '@entities/cluster/ssh-key-block/model/const.ts'; +import { LOAD_BALANCERS_FIELD_NAMES } from '@entities/cluster/load-balancers-block/model/const.ts'; +import { AUTHENTICATION_METHODS, IS_EXPERT_MODE } from '@shared/model/constants.ts'; +import { + SECRET_MODAL_CONTENT_BODY_FORM_FIELDS, + SECRET_MODAL_CONTENT_FORM_FIELD_NAMES, +} from '@entities/secret-form-block/model/constants.ts'; +import { PROVIDERS } from '@shared/config/constants.ts'; +import { INSTANCES_AMOUNT_BLOCK_VALUES } from '@entities/cluster/instances-amount-block/model/const.ts'; +import { NETWORK_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/network-block/model/const.ts'; +import { DCS_BLOCK_FIELD_NAMES, DCS_TYPES } from '@entities/cluster/expert-mode/dcs-block/model/const.ts'; +import { DATA_DIRECTORY_FIELD_NAMES } from '@entities/cluster/expert-mode/data-directory-block/model/const.ts'; +import { DATABASE_SERVERS_FIELD_NAMES } from '@entities/cluster/database-servers-block/model/const.ts'; +import { EXTENSION_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/extensions-block/model/const.ts'; +import { DATABASES_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/databases-block/model/const.ts'; +import { CONNECTION_POOLS_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/connection-pools-block/model/const.ts'; +import { ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/additional-settings-block/model/const.ts'; +import { BACKUP_METHODS, BACKUPS_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/backups-block/model/const.ts'; +import { POSTGRES_PARAMETERS_FIELD_NAMES } from '@entities/cluster/expert-mode/postgres-parameters-block/model/const.ts'; +import { KERNEL_PARAMETERS_FIELD_NAMES } from '@entities/cluster/expert-mode/kernel-parameters-block/model/const.ts'; +import { RequestClusterCreate } from '@shared/api/api/clusters.ts'; + +/** + * Get value from modal form (postgres or kernel params) and convert to correct format. + * @param value - Form value. + */ +export const convertModalParametersToArray = (value?: string) => + value?.length + ? value.split(/[\n\r]/).map((item) => { + const values = item.split(/[:=]/); + return { + option: values?.[0].trim(), // due to splitting rule, values might have unnecessary whitespaces that needs to be removed + value: values?.[1].trim(), + }; + }) + : value; + +/** + * Functions creates an object with shared cluster envs that should be put in 'extra_vars' request field. + * @param values - Filled form values. + */ +export const getCommonExtraVars = (values: ClusterFormValues) => ({ + postgresql_version: values[CLUSTER_FORM_FIELD_NAMES.POSTGRES_VERSION], + patroni_cluster_name: values[CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME], +}); + +/** + * Functions creates an object with envs exclusive to cloud clusters that should be put in 'extra_vars' request field. + * @param values - Filled form values. + */ +export const getCloudProviderExtraVars = (values: ClusterFormValues) => ({ + ...getCommonExtraVars(values), + cloud_provider: values[CLUSTER_FORM_FIELD_NAMES.PROVIDER].code, + server_type: + values?.[CLUSTER_FORM_FIELD_NAMES.INSTANCE_TYPE] === 'custom' + ? values[INSTANCES_BLOCK_FIELD_NAMES.SERVER_TYPE] + : values[CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG].code, + server_location: values[CLUSTER_FORM_FIELD_NAMES.REGION_CONFIG].code, + server_count: values[CLUSTER_FORM_FIELD_NAMES.INSTANCES_AMOUNT], + volume_size: values[STORAGE_BLOCK_FIELDS.STORAGE_AMOUNT], + ansible_user: PROVIDER_CODE_TO_ANSIBLE_USER_MAP[values[CLUSTER_FORM_FIELD_NAMES.PROVIDER].code], + ...(values[SSH_KEY_BLOCK_FIELD_NAMES.SSH_PUBLIC_KEY]?.length + ? { ssh_public_keys: values[SSH_KEY_BLOCK_FIELD_NAMES.SSH_PUBLIC_KEY].split(/[\n\r]/).map((key) => `'${key}'`) } + : {}), + ...values[CLUSTER_FORM_FIELD_NAMES.REGION_CONFIG].cloud_image.image, + ...(IS_EXPERT_MODE + ? { + postgresql_data_dir_mount_fstype: values[STORAGE_BLOCK_FIELDS.FILE_SYSTEM_TYPE], + volume_type: values[STORAGE_BLOCK_FIELDS.VOLUME_TYPE], + database_public_access: !!values?.[ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.IS_DB_PUBLIC_ACCESS], + cloud_load_balancer: !!values?.[ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.IS_CLOUD_LOAD_BALANCER], + ...([PROVIDERS.AWS, PROVIDERS.GCP, PROVIDERS.AZURE].includes(values[CLUSTER_FORM_FIELD_NAMES.PROVIDER]?.code) && + !!values[INSTANCES_AMOUNT_BLOCK_VALUES.IS_SPOT_INSTANCES] + ? { + server_spot: true, + } + : {}), + ...(values[NETWORK_BLOCK_FIELD_NAMES.SERVER_NETWORK] + ? { server_network: values[NETWORK_BLOCK_FIELD_NAMES.SERVER_NETWORK] } + : {}), + } + : {}), +}); + +/** + * Functions creates an object with envs exclusive to local clusters that should be put in 'extra_vars' request field. + * @param values - Filled form values. + * @param secretId - Optional ID of secret if exists. + */ +export const getLocalMachineExtraVars = (values: ClusterFormValues, secretId?: number) => ({ + ...getCommonExtraVars(values), + ...(values[CLUSTER_FORM_FIELD_NAMES.CLUSTER_VIP_ADDRESS] + ? { cluster_vip: values[CLUSTER_FORM_FIELD_NAMES.CLUSTER_VIP_ADDRESS] } + : {}), + ...(values[LOAD_BALANCERS_FIELD_NAMES.IS_HAPROXY_ENABLED] ? { with_haproxy_load_balancing: true } : {}), + ...(!secretId && + !values[CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET] && + values[CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD] === AUTHENTICATION_METHODS.PASSWORD + ? { + ansible_user: values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.USERNAME], + ansible_ssh_pass: values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.PASSWORD], + } + : {}), + ...(IS_EXPERT_MODE + ? { + dcs_type: values?.[DCS_BLOCK_FIELD_NAMES.TYPE], + ...(!values[DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_NEW_CLUSTER] + ? { + dcs_exists: true, + ...(values[DCS_BLOCK_FIELD_NAMES.TYPE] === DCS_TYPES.ETCD + ? { + patroni_etcd_hosts: values?.[DCS_BLOCK_FIELD_NAMES.DCS_DATABASES]?.map((database) => ({ + host: database[DCS_BLOCK_FIELD_NAMES.DCS_DATABASE_IP_ADDRESS], + port: database[DCS_BLOCK_FIELD_NAMES.DCS_DATABASE_PORT], + })), + } + : { + consul_join: values?.[DCS_BLOCK_FIELD_NAMES.DCS_DATABASES]?.map( + (database) => database[DCS_BLOCK_FIELD_NAMES.DCS_DATABASE_IP_ADDRESS], + ), + consul_ports_serf_lan: 8301, + }), + } + : {}), + postgresql_data_dir: values?.[DATA_DIRECTORY_FIELD_NAMES.DATA_DIRECTORY], + } + : {}), +}); + +/** + * Function maps a field array into correct request format for DCS config. + * @param values - Filled form values. + * @param role - Optional role for Consul instances. + * @param shouldAddHostname - An optional flag determines if field 'hostname' should be added. True by default. + * @param isDbServers - An optional flag determines which db servers are mapping - Database servers or DCS. True by default. + */ +const configureHosts = ({ + values, + role, + shouldAddHostname = false, + isDbServers = true, +}: { + values: ClusterFormValues; + role?: string; + shouldAddHostname?: boolean; + isDbServers?: boolean; +}) => { + const dbServersKeys = { + servers: DATABASE_SERVERS_FIELD_NAMES.DATABASE_SERVERS, + ipAddress: DATABASE_SERVERS_FIELD_NAMES.DATABASE_IP_ADDRESS, + }; + + const dcsHostsKeys = { + servers: DCS_BLOCK_FIELD_NAMES.DCS_DATABASES, + ipAddress: DCS_BLOCK_FIELD_NAMES.DCS_DATABASE_IP_ADDRESS, + hostname: DCS_BLOCK_FIELD_NAMES.DCS_DATABASE_HOSTNAME, + }; + + const usedKeys = isDbServers ? dbServersKeys : dcsHostsKeys; + + return values[usedKeys.servers].reduce( + (acc, server) => ({ + ...acc, + [server[usedKeys.ipAddress]]: { + ansible_host: server[usedKeys.ipAddress], + ...(shouldAddHostname && usedKeys?.hostname ? { hostname: server[usedKeys.hostname] } : {}), + ...(role ? { consul_node_role: role } : {}), + }, + }), + {}, + ); +}; + +/** + * Function maps DCS fields into the correct request format. + * @param values - Filled form values. + */ +const constructDcsEnvs = (values: ClusterFormValues) => { + if (values[DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_NEW_CLUSTER]) { + if (!IS_EXPERT_MODE) { + return { + etcd_cluster: { + hosts: configureHosts({ values }), + }, + consul_instances: { hosts: {} }, + }; + } + if (IS_EXPERT_MODE) { + switch (values[DCS_BLOCK_FIELD_NAMES.TYPE]) { + case DCS_TYPES.ETCD: + return { + etcd_cluster: { + hosts: configureHosts({ + values, + isDbServers: values[DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_TO_DB_SERVERS], + shouldAddHostname: !values[DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_TO_DB_SERVERS], + }), + }, + consul_instances: { hosts: {} }, + }; + case DCS_TYPES.CONSUL: + return { + etcd_cluster: { + hosts: {}, + }, + consul_instances: { + hosts: values[DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_TO_DB_SERVERS] + ? configureHosts({ values, role: 'server' }) + : { + ...configureHosts({ values, role: 'client' }), + ...configureHosts({ values, role: 'server', isDbServers: false, shouldAddHostname: true }), + }, + }, + }; + default: + return { + etcd_cluster: { hosts: {} }, + consul_instances: { + hosts: {}, + }, + }; + } + } + } + if (IS_EXPERT_MODE && !values[DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_NEW_CLUSTER]) { + if (values[DCS_BLOCK_FIELD_NAMES.TYPE] === DCS_TYPES.CONSUL) { + return { + consul_instances: { + hosts: configureHosts({ values, role: 'client' }), + }, + }; + } + } +}; + +/** + * Function maps Load Balancers block form values into correct request format. + * @param values - Filled form values. + */ +const constructBalancersEnvs = (values: ClusterFormValues) => { + let balancerHosts = {}; + + if (values[LOAD_BALANCERS_FIELD_NAMES.IS_HAPROXY_ENABLED]) { + if (IS_EXPERT_MODE && !values[LOAD_BALANCERS_FIELD_NAMES.IS_DEPLOY_TO_DATABASE_SERVERS]) { + balancerHosts = values[LOAD_BALANCERS_FIELD_NAMES.DATABASES].reduce( + (acc, server) => ({ + ...acc, + [server[LOAD_BALANCERS_FIELD_NAMES.DATABASES_ADDRESS]]: { + ansible_host: server[LOAD_BALANCERS_FIELD_NAMES.DATABASES_ADDRESS], + }, + }), + {}, + ); + } else { + balancerHosts = values[DATABASE_SERVERS_FIELD_NAMES.DATABASE_SERVERS].reduce( + (acc, server) => ({ + ...acc, + [server[DATABASE_SERVERS_FIELD_NAMES.DATABASE_IP_ADDRESS]]: { + ansible_host: server[DATABASE_SERVERS_FIELD_NAMES.DATABASE_IP_ADDRESS], + }, + }), + {}, + ); + } + } + + return { + balancers: { + hosts: balancerHosts, + }, + }; +}; + +/** + * Functions creates an object with envs exclusive to local clusters. + * @param values - Filled form values. + * @param secretId - Optional ID of secret if exists. + */ +export const getLocalMachineEnvs = (values: ClusterFormValues, secretId?: number) => ({ + ...(values[CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD] === AUTHENTICATION_METHODS.SSH && + !values[CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET] && + !secretId + ? { + SSH_PRIVATE_KEY_CONTENT: values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SSH_PRIVATE_KEY], + } + : {}), + ANSIBLE_INVENTORY_JSON: { + all: { + vars: { + ansible_user: values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.USERNAME], + ...(values[CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD] === AUTHENTICATION_METHODS.PASSWORD + ? { + ansible_ssh_pass: values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.USERNAME], + ansible_sudo_pass: values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.PASSWORD], + } + : {}), + }, + children: { + ...constructBalancersEnvs(values), + ...constructDcsEnvs(values), + master: { + hosts: { + [values[DATABASE_SERVERS_FIELD_NAMES.DATABASE_SERVERS][0][ + DATABASE_SERVERS_FIELD_NAMES.DATABASE_IP_ADDRESS + ]]: { + hostname: + values[DATABASE_SERVERS_FIELD_NAMES.DATABASE_SERVERS][0][ + DATABASE_SERVERS_FIELD_NAMES.DATABASE_HOSTNAME + ], + ansible_host: + values[DATABASE_SERVERS_FIELD_NAMES.DATABASE_SERVERS][0][ + DATABASE_SERVERS_FIELD_NAMES.DATABASE_IP_ADDRESS + ], + server_location: + values[DATABASE_SERVERS_FIELD_NAMES.DATABASE_SERVERS]?.[0]?.[ + DATABASE_SERVERS_FIELD_NAMES.DATABASE_LOCATION + ], + postgresql_exists: IS_EXPERT_MODE + ? values[DATABASE_SERVERS_FIELD_NAMES.DATABASE_SERVERS]?.[0]?.[ + DATABASE_SERVERS_FIELD_NAMES.IS_POSTGRESQL_EXISTS + ] + : (values[DATABASE_SERVERS_FIELD_NAMES.IS_CLUSTER_EXISTS] ?? false), + }, + }, + }, + ...(values[DATABASE_SERVERS_FIELD_NAMES.DATABASE_SERVERS].length > 1 + ? { + replica: { + hosts: values[DATABASE_SERVERS_FIELD_NAMES.DATABASE_SERVERS].slice(1).reduce( + (acc, server) => ({ + ...acc, + [server[DATABASE_SERVERS_FIELD_NAMES.DATABASE_IP_ADDRESS]]: { + hostname: server?.[DATABASE_SERVERS_FIELD_NAMES.DATABASE_HOSTNAME], + ansible_host: server?.[DATABASE_SERVERS_FIELD_NAMES.DATABASE_IP_ADDRESS], + server_location: server?.[DATABASE_SERVERS_FIELD_NAMES.DATABASE_LOCATION], + postgresql_exists: IS_EXPERT_MODE + ? server?.[DATABASE_SERVERS_FIELD_NAMES.IS_POSTGRESQL_EXISTS] + : (values[DATABASE_SERVERS_FIELD_NAMES.IS_CLUSTER_EXISTS] ?? false), + }, + }), + {}, + ), + }, + } + : {}), + postgres_cluster: { + children: { + master: {}, + replica: {}, + }, + }, + }, + }, + }, +}); + +/** + * Function converts 'extensions' form value into request format. + * @param values - Filled form values. + */ +const getExtensions = (values: ClusterFormValues) => + Object.entries(values?.[EXTENSION_BLOCK_FIELD_NAMES.EXTENSIONS])?.reduce((acc, [key, value]) => { + if (value?.length) { + const convertedToReqFormat = value.map((item) => ({ + ext: key, + db: values[DATABASES_BLOCK_FIELD_NAMES.NAMES][item], + })); + return [...acc, ...convertedToReqFormat]; + } + return acc; + }, []) ?? []; + +/** + * Functions creates an object with base cluster extra_vars shared between cloud and local clusters. + * @param values - Filled form values. + */ +export const getBaseClusterExtraVars = (values: ClusterFormValues) => { + const extensions = IS_EXPERT_MODE ? getExtensions(values) : []; + + return IS_EXPERT_MODE + ? { + postgresql_databases: values[DATABASES_BLOCK_FIELD_NAMES.DATABASES]?.map((db) => ({ + db: db?.[DATABASES_BLOCK_FIELD_NAMES.DATABASE_NAME], + owner: db?.[DATABASES_BLOCK_FIELD_NAMES.USER_NAME], + encoding: db?.[DATABASES_BLOCK_FIELD_NAMES.ENCODING], + lc_ctype: db?.[DATABASES_BLOCK_FIELD_NAMES.LOCALE], + lc_collate: db?.[DATABASES_BLOCK_FIELD_NAMES.LOCALE], + })), + postgresql_users: values[DATABASES_BLOCK_FIELD_NAMES.DATABASES]?.map((db) => ({ + name: db?.[DATABASES_BLOCK_FIELD_NAMES.USER_NAME], + password: db?.[DATABASES_BLOCK_FIELD_NAMES.USER_PASSWORD], + })), + pgbouncer_install: !!values[CONNECTION_POOLS_BLOCK_FIELD_NAMES.IS_CONNECTION_POOLER_ENABLED], + netdata_install: !!values?.[ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.IS_NETDATA_MONITORING], + ...(values[CONNECTION_POOLS_BLOCK_FIELD_NAMES.IS_CONNECTION_POOLER_ENABLED] // do not add pools info if connection pooler is disabled + ? { + pgbouncer_pools: values?.[CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOLS]?.map((pool) => ({ + name: pool?.[CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOL_NAME], + dbname: pool?.[CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOL_NAME], + pool_parameters: { + pool_size: pool?.[CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOL_SIZE], + pool_mode: pool?.[CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOL_MODE], + }, + })), + } + : {}), + ...(extensions?.length ? { postgresql_extensions: extensions } : {}), + ...(values?.[BACKUPS_BLOCK_FIELD_NAMES.IS_BACKUPS_ENABLED] && values?.[BACKUPS_BLOCK_FIELD_NAMES.BACKUP_METHOD] + ? values[BACKUPS_BLOCK_FIELD_NAMES.BACKUP_METHOD] === BACKUP_METHODS.PG_BACK_REST + ? { + pgbackrest_install: true, + pgbackrest_backup_hour: values?.[BACKUPS_BLOCK_FIELD_NAMES.BACKUP_START_TIME], + pgbackrest_retention_full: values?.[BACKUPS_BLOCK_FIELD_NAMES.BACKUP_RETENTION], + pgbackrest_retention_archive: values?.[BACKUPS_BLOCK_FIELD_NAMES.BACKUP_RETENTION], + ...(values?.[BACKUPS_BLOCK_FIELD_NAMES.CONFIG] + ? { + pgbackrest_conf: { + global: convertModalParametersToArray(values?.[BACKUPS_BLOCK_FIELD_NAMES.CONFIG]), + }, + } + : {}), + ...([PROVIDERS.DIGITAL_OCEAN, PROVIDERS.HETZNER].includes( + values?.[CLUSTER_FORM_FIELD_NAMES.PROVIDER]?.code, + ) + ? { + pgbackrest_s3_key: values?.[BACKUPS_BLOCK_FIELD_NAMES.ACCESS_KEY], + pgbackrest_s3_key_secret: values?.[BACKUPS_BLOCK_FIELD_NAMES.SECRET_KEY], + } + : {}), + } + : { + wal_g_install: true, + wal_g_backup_hour: values?.[BACKUPS_BLOCK_FIELD_NAMES.BACKUP_START_TIME], + wal_g_retention_full: values?.[BACKUPS_BLOCK_FIELD_NAMES.BACKUP_RETENTION], + ...(values?.[BACKUPS_BLOCK_FIELD_NAMES.CONFIG] + ? { + wal_g_json: convertModalParametersToArray(values?.[BACKUPS_BLOCK_FIELD_NAMES.CONFIG]), + } + : {}), + ...([PROVIDERS.DIGITAL_OCEAN, PROVIDERS.HETZNER].includes( + values?.[CLUSTER_FORM_FIELD_NAMES.PROVIDER]?.code, + ) + ? { + wal_g_aws_access_key_id: values?.[BACKUPS_BLOCK_FIELD_NAMES.ACCESS_KEY], + wal_g_aws_secret_access_key: values?.[BACKUPS_BLOCK_FIELD_NAMES.SECRET_KEY], + } + : {}), + } + : {}), + ...(values?.[POSTGRES_PARAMETERS_FIELD_NAMES.POSTGRES_PARAMETERS] + ? { + local_postgresql_parameters: convertModalParametersToArray( + values?.[POSTGRES_PARAMETERS_FIELD_NAMES.POSTGRES_PARAMETERS], + ), + } + : {}), + ...(values?.[KERNEL_PARAMETERS_FIELD_NAMES.KERNEL_PARAMETERS] + ? { + sysctl_set: true, + sysctl_conf: { + postgres_cluster: convertModalParametersToArray( + values?.[KERNEL_PARAMETERS_FIELD_NAMES.KERNEL_PARAMETERS], + ), + }, + } + : {}), + ...(values?.[ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.SYNC_STANDBY_NODES] + ? { + synchronous_mode: true, + synchronous_node_count: values[ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.SYNC_STANDBY_NODES], + ...(values?.[ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.IS_SYNC_MODE_STRICT] + ? { synchronous_mode_strict: true } + : {}), + } + : {}), + } + : {}; +}; + +const convertObjectValueToBase64Format = (object: Record) => + Object.entries(object).reduce((acc: string[], [key, value]) => [...acc, `${key}=${btoa(JSON.stringify(value))}`], []); + +const getRequestCloudParams = (values, secretsInfo, customExtraVars) => ({ + envs: convertObjectValueToBase64Format({ + ...Object.fromEntries( + Object.entries({ + ...secretsInfo, + }).filter(([key]) => SECRET_MODAL_CONTENT_BODY_FORM_FIELDS?.[key]), + ), + }), + extra_vars: customExtraVars ?? { + ...getBaseClusterExtraVars(values), + ...getCloudProviderExtraVars(values), + }, +}); + +const getRequestLocalMachineParams = (values, secretId, customExtraVars) => ({ + envs: convertObjectValueToBase64Format(getLocalMachineEnvs(values, secretId)), + extra_vars: customExtraVars ?? { + ...getBaseClusterExtraVars(values), + ...getLocalMachineExtraVars(values, secretId), + }, + existing_cluster: values[DATABASE_SERVERS_FIELD_NAMES.IS_CLUSTER_EXISTS] ?? false, +}); + +/** + * Functions creates an object with fields and values in format required by API. + * @param values - Filled form values. + * @param secretId - Optional ID of secret if exists. + * @param projectId - Optional ID of a current project. + * @param secretsInfo - Optional object with secret information. + * @param customExtraVars - Optional parameter with custom extra vars (from YAML editor). + */ +export const mapFormValuesToRequestFields = ({ + values, + secretId, + projectId, + secretsInfo, + customExtraVars, +}: { + values: ClusterFormValues; + secretId?: number; + projectId: number; + secretsInfo?: object; + customExtraVars?: Record; +}): RequestClusterCreate => { + const baseObject = { + name: values[CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME], + environment_id: values[CLUSTER_FORM_FIELD_NAMES.ENVIRONMENT_ID], + description: values[CLUSTER_FORM_FIELD_NAMES.DESCRIPTION], + ...(secretId ? { auth_info: { secret_id: secretId } } : {}), + project_id: projectId, + }; + + return { + ...baseObject, + ...(values[CLUSTER_FORM_FIELD_NAMES.PROVIDER].code !== PROVIDERS.LOCAL + ? getRequestCloudParams(values, secretsInfo, customExtraVars) + : getRequestLocalMachineParams(values, secretId, customExtraVars)), + }; +}; diff --git a/console/ui/src/shared/model/constants.ts b/console/ui/src/shared/model/constants.ts index 09db1a02be..6a0cf27adc 100644 --- a/console/ui/src/shared/model/constants.ts +++ b/console/ui/src/shared/model/constants.ts @@ -3,3 +3,16 @@ export const AUTHENTICATION_METHODS = Object.freeze({ SSH: 'ssh_key', PASSWORD: 'password', }); + +export const LOCAL_STORAGE_ITEMS = Object.freeze({ + IS_EXPERT_MODE: 'isExpertMode', + IS_YAML_ENABLED: 'isYamlEnabled', +}); + +export let IS_EXPERT_MODE = localStorage.getItem(LOCAL_STORAGE_ITEMS.IS_EXPERT_MODE)?.toString() === 'true'; +export let IS_YAML_ENABLED = localStorage.getItem(LOCAL_STORAGE_ITEMS.IS_YAML_ENABLED)?.toString() === 'true'; + +window.addEventListener('storage', () => { + IS_EXPERT_MODE = localStorage.getItem(LOCAL_STORAGE_ITEMS.IS_EXPERT_MODE)?.toString() === 'true'; // TODO: refactor + IS_YAML_ENABLED = localStorage.getItem(LOCAL_STORAGE_ITEMS.IS_YAML_ENABLED)?.toString() === 'true'; // TODO: refactor +}); diff --git a/console/ui/src/shared/model/types.ts b/console/ui/src/shared/model/types.ts index 9dbbf45018..92628385fe 100644 --- a/console/ui/src/shared/model/types.ts +++ b/console/ui/src/shared/model/types.ts @@ -4,3 +4,5 @@ export interface TableRowActionsProps { closeMenu: () => void; row: MRT_Row; } + +export type valueOf = T[keyof T]; diff --git a/console/ui/src/shared/model/validation.ts b/console/ui/src/shared/model/validation.ts new file mode 100644 index 0000000000..dcd4266e36 --- /dev/null +++ b/console/ui/src/shared/model/validation.ts @@ -0,0 +1,14 @@ +import * as yup from 'yup'; +import { TFunction } from 'i18next'; + +export const configValidationSchema = (t: TFunction) => + yup + .string() + .test( + 'should have correct format', + t('configFormat', { ns: 'validation' }), + (value) => + /^[^:=\n\r]+:[^:=\n\r]+([\n\r][^:=\n\r]+:[^:=\n\r]+)*$/i.test(value) || + /^[^:=\n\r]+=[^:=\n\r]+([\n\r][^:=\n\r]+=[^:=\n\r]+)*$/i.test(value) || + value === '', + ); diff --git a/console/ui/src/shared/theme/theme.ts b/console/ui/src/shared/theme/theme.ts index 5c6440cf96..fdc60e05fd 100644 --- a/console/ui/src/shared/theme/theme.ts +++ b/console/ui/src/shared/theme/theme.ts @@ -130,7 +130,7 @@ export const createAppTheme = (mode: PaletteMode) => { backgroundColor: isLight ? '#ffffff' : '#1a1a1a', borderColor: isLight ? '#e1e5e9' : '#2a2a2a', // Enhanced shadows for dark mode - boxShadow: isLight + boxShadow: isLight ? '0px 2px 6px rgba(0, 0, 0, 0.1)' : '0px 4px 16px rgba(0, 0, 0, 0.2), 0px 0px 0px 1px rgba(255, 255, 255, 0.05)', }, @@ -145,239 +145,235 @@ export const createAppTheme = (mode: PaletteMode) => { }, }, MuiMenuItem: { - styleOverrides: { - root: { - color: isLight ? '#384555' : '#dddddd', - '&:hover': { - backgroundColor: isLight ? '#f5f5f5' : '#404040', - }, - '&.Mui-selected': { - backgroundColor: isLight ? '#e3f2fd' : '#1e3a8a', - '&:hover': { - backgroundColor: isLight ? '#bbdefb' : '#1e40af', - }, - }, - }, - }, - }, - MuiListItemButton: { - styleOverrides: { - root: { - color: isLight ? '#384555' : '#dddddd', - '&:hover': { - backgroundColor: isLight ? '#f5f5f5' : '#2a2a2a', - }, - }, - }, - }, - MuiListItemText: { - styleOverrides: { - primary: { - color: isLight ? '#384555' : '#dddddd', - }, - }, - }, - MuiListItemIcon: { - styleOverrides: { - root: { - color: isLight ? '#384555' : '#dddddd', - '& svg': { - fill: isLight ? '#384555' : '#dddddd !important', - '& path': { - fill: isLight ? '#384555' : '#dddddd !important', - }, - }, - }, - }, - }, - MuiIconButton: { - styleOverrides: { - root: { - color: isLight ? '#384555' : '#dddddd', - '&:hover': { - backgroundColor: isLight ? '#f5f5f5' : '#2a2a2a', - }, - '& svg': { - fill: isLight ? '#384555' : '#dddddd', - }, - }, - }, - }, - MuiTable: { - styleOverrides: { - root: { - backgroundColor: isLight ? '#ffffff' : '#1a1a1a', - '& .MuiTableHead-root': { - backgroundColor: isLight ? '#F6F8FA' : '#2a2a2a', - }, - '& .MuiTableCell-root': { - borderBottomColor: isLight ? '#e1e5e9' : '#404040', - color: isLight ? '#384555' : '#dddddd', - }, - '& .MuiTableCell-head': { - backgroundColor: isLight ? '#F6F8FA' : '#2a2a2a', - color: isLight ? '#384555' : '#dddddd', - fontWeight: 600, - }, - }, - }, - }, - MuiTableContainer: { - styleOverrides: { - root: { - backgroundColor: isLight ? '#ffffff' : '#1a1a1a', - border: `1px solid ${isLight ? '#e1e5e9' : '#404040'}`, - borderRadius: '8px', - }, - }, - }, - MuiToolbar: { - styleOverrides: { - root: { - backgroundColor: isLight ? '#F6F8FA' : '#242526', - color: isLight ? '#384555' : '#dddddd', - }, - }, - }, - MuiButton: { - styleOverrides: { - root: { - textTransform: 'none', - // Enhanced focus states for accessibility - '&:focus-visible': { - outline: `2px solid ${isLight ? '#3367D6' : '#5A8DEE'}`, - outlineOffset: '2px', - }, - }, - outlined: { - borderColor: isLight ? '#e1e5e9' : '#404040', - color: isLight ? '#384555' : '#dddddd', - '&:hover': { - borderColor: isLight ? '#3367D6' : '#5A8DEE', - backgroundColor: isLight ? '#f5f5f5' : '#2a2a2a', - }, - '&:focus-visible': { - borderColor: isLight ? '#3367D6' : '#5A8DEE', - backgroundColor: isLight ? '#f8f9fa' : '#2d2d2d', - }, - }, - contained: { - '&:focus-visible': { - boxShadow: isLight - ? '0 0 0 3px rgba(51, 103, 214, 0.3)' - : '0 0 0 3px rgba(90, 141, 238, 0.4)', - }, - }, - }, - }, - MuiChip: { - styleOverrides: { - root: { - backgroundColor: isLight ? '#f5f5f5' : '#2a2a2a', - color: isLight ? '#384555' : '#dddddd', - }, - }, - }, - MuiTooltip: { - styleOverrides: { - tooltip: { - backgroundColor: isLight ? '#2a2a2a' : '#ffffff', - color: isLight ? '#ffffff' : '#2a2a2a', - }, - }, - }, - MuiDialog: { - styleOverrides: { - paper: { - backgroundColor: isLight ? '#ffffff' : '#1a1a1a', - color: isLight ? '#384555' : '#dddddd', - }, - }, - }, - MuiDialogTitle: { - styleOverrides: { - root: { - color: isLight ? '#384555' : '#dddddd', - }, - }, - }, - MuiDialogContent: { - styleOverrides: { - root: { - color: isLight ? '#384555' : '#dddddd', - }, - }, - }, - MuiLinearProgress: { - styleOverrides: { - root: { - backgroundColor: isLight ? '#e1e5e9' : '#404040', - '& .MuiLinearProgress-bar': { - backgroundColor: isLight ? '#3367D6' : '#5A8DEE', - }, - }, - }, - }, - MuiSkeleton: { - styleOverrides: { - root: { - backgroundColor: isLight ? '#f0f0f0' : '#2a2a2a', - '&::after': { - background: isLight - ? 'linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent)' - : 'linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent)', - }, - }, - }, - }, - MuiAccordion: { - styleOverrides: { - root: { - backgroundColor: isLight ? '#ffffff' : '#1a1a1a', - border: `1px solid ${isLight ? '#e1e5e9' : '#2a2a2a'}`, - borderRadius: '8px !important', - boxShadow: isLight - ? '0px 1px 3px rgba(0, 0, 0, 0.1)' - : '0px 2px 8px rgba(0, 0, 0, 0.2)', - '&:before': { - display: 'none', - }, - '&.Mui-expanded': { - margin: '0', - }, - }, - }, - }, - MuiAccordionSummary: { - styleOverrides: { - root: { - backgroundColor: isLight ? '#f8f9fa' : '#222222', - borderBottom: `1px solid ${isLight ? '#e1e5e9' : '#2a2a2a'}`, - minHeight: '56px', - '&.Mui-expanded': { - minHeight: '56px', - }, - '& .MuiAccordionSummary-content': { - margin: '12px 0', - '&.Mui-expanded': { - margin: '12px 0', - }, - }, - }, - }, - }, - MuiAccordionDetails: { - styleOverrides: { - root: { - backgroundColor: isLight ? '#ffffff' : '#1a1a1a', - padding: '16px', + styleOverrides: { + root: { + color: isLight ? '#384555' : '#dddddd', + '&:hover': { + backgroundColor: isLight ? '#f5f5f5' : '#404040', + }, + '&.Mui-selected': { + backgroundColor: isLight ? '#e3f2fd' : '#1e3a8a', + '&:hover': { + backgroundColor: isLight ? '#bbdefb' : '#1e40af', + }, + }, + }, + }, + }, + MuiListItemButton: { + styleOverrides: { + root: { + color: isLight ? '#384555' : '#dddddd', + '&:hover': { + backgroundColor: isLight ? '#f5f5f5' : '#2a2a2a', + }, + }, + }, + }, + MuiListItemText: { + styleOverrides: { + primary: { + color: isLight ? '#384555' : '#dddddd', + }, + }, + }, + MuiListItemIcon: { + styleOverrides: { + root: { + color: isLight ? '#384555' : '#dddddd', + '& svg': { + fill: isLight ? '#384555' : '#dddddd !important', + '& path': { + fill: isLight ? '#384555' : '#dddddd !important', + }, + }, + }, + }, + }, + MuiIconButton: { + styleOverrides: { + root: { + color: isLight ? '#384555' : '#dddddd', + '&:hover': { + backgroundColor: isLight ? '#f5f5f5' : '#2a2a2a', + }, + '& svg': { + fill: isLight ? '#384555' : '#dddddd', + }, + }, + }, + }, + MuiTable: { + styleOverrides: { + root: { + backgroundColor: isLight ? '#ffffff' : '#1a1a1a', + '& .MuiTableHead-root': { + backgroundColor: isLight ? '#F6F8FA' : '#2a2a2a', + }, + '& .MuiTableCell-root': { + borderBottomColor: isLight ? '#e1e5e9' : '#404040', + color: isLight ? '#384555' : '#dddddd', + }, + '& .MuiTableCell-head': { + backgroundColor: isLight ? '#F6F8FA' : '#2a2a2a', + color: isLight ? '#384555' : '#dddddd', + fontWeight: 600, + }, + }, + }, + }, + MuiTableContainer: { + styleOverrides: { + root: { + backgroundColor: isLight ? '#ffffff' : '#1a1a1a', + border: `1px solid ${isLight ? '#e1e5e9' : '#404040'}`, + borderRadius: '8px', + }, + }, + }, + MuiToolbar: { + styleOverrides: { + root: { + backgroundColor: isLight ? '#F6F8FA' : '#242526', + color: isLight ? '#384555' : '#dddddd', + }, + }, + }, + MuiButton: { + styleOverrides: { + root: { + textTransform: 'none', + // Enhanced focus states for accessibility + '&:focus-visible': { + outline: `2px solid ${isLight ? '#3367D6' : '#5A8DEE'}`, + outlineOffset: '2px', + }, + }, + outlined: { + borderColor: isLight ? '#e1e5e9' : '#404040', + color: isLight ? '#384555' : '#dddddd', + '&:hover': { + borderColor: isLight ? '#3367D6' : '#5A8DEE', + backgroundColor: isLight ? '#f5f5f5' : '#2a2a2a', + }, + '&:focus-visible': { + borderColor: isLight ? '#3367D6' : '#5A8DEE', + backgroundColor: isLight ? '#f8f9fa' : '#2d2d2d', + }, + }, + contained: { + '&:focus-visible': { + boxShadow: isLight ? '0 0 0 3px rgba(51, 103, 214, 0.3)' : '0 0 0 3px rgba(90, 141, 238, 0.4)', + }, + }, + }, + }, + MuiChip: { + styleOverrides: { + root: { + backgroundColor: isLight ? '#f5f5f5' : '#2a2a2a', + color: isLight ? '#384555' : '#dddddd', + }, + }, + }, + MuiTooltip: { + styleOverrides: { + tooltip: { + backgroundColor: isLight ? '#2a2a2a' : '#ffffff', + color: isLight ? '#ffffff' : '#2a2a2a', + }, + }, + }, + MuiDialog: { + styleOverrides: { + paper: { + backgroundColor: isLight ? '#ffffff' : '#1a1a1a', + color: isLight ? '#384555' : '#dddddd', + }, + }, + }, + MuiDialogTitle: { + styleOverrides: { + root: { + color: isLight ? '#384555' : '#dddddd', + }, + }, + }, + MuiDialogContent: { + styleOverrides: { + root: { + color: isLight ? '#384555' : '#dddddd', + }, + }, + }, + MuiLinearProgress: { + styleOverrides: { + root: { + backgroundColor: isLight ? '#e1e5e9' : '#404040', + '& .MuiLinearProgress-bar': { + backgroundColor: isLight ? '#3367D6' : '#5A8DEE', + }, + }, + }, + }, + MuiSkeleton: { + styleOverrides: { + root: { + backgroundColor: isLight ? '#f0f0f0' : '#2a2a2a', + '&::after': { + background: isLight + ? 'linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent)' + : 'linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent)', + }, + }, + }, + }, + MuiAccordion: { + styleOverrides: { + root: { + backgroundColor: isLight ? '#ffffff' : '#1a1a1a', + border: `1px solid ${isLight ? '#e1e5e9' : '#2a2a2a'}`, + borderRadius: '8px !important', + boxShadow: isLight ? '0px 1px 3px rgba(0, 0, 0, 0.1)' : '0px 2px 8px rgba(0, 0, 0, 0.2)', + '&:before': { + display: 'none', + }, + '&.Mui-expanded': { + margin: '0', + }, + }, + }, + }, + MuiAccordionSummary: { + styleOverrides: { + root: { + backgroundColor: isLight ? '#f8f9fa' : '#222222', + borderBottom: `1px solid ${isLight ? '#e1e5e9' : '#2a2a2a'}`, + minHeight: '56px', + '&.Mui-expanded': { + minHeight: '56px', + }, + '& .MuiAccordionSummary-content': { + margin: '12px 0', + '&.Mui-expanded': { + margin: '12px 0', + }, + }, + }, + }, + }, + MuiAccordionDetails: { + styleOverrides: { + root: { + backgroundColor: isLight ? '#ffffff' : '#1a1a1a', + padding: '16px', + }, }, }, }, }, - }, - enUS, -); + enUS, + ); }; // Default light theme for backward compatibility diff --git a/console/ui/src/shared/ui/error-box/model/types.ts b/console/ui/src/shared/ui/error-box/model/types.ts new file mode 100644 index 0000000000..d6c6dcc083 --- /dev/null +++ b/console/ui/src/shared/ui/error-box/model/types.ts @@ -0,0 +1,3 @@ +export interface ErrorBoxProps { + text?: string; +} diff --git a/console/ui/src/shared/ui/error-box/ui/index.tsx b/console/ui/src/shared/ui/error-box/ui/index.tsx new file mode 100644 index 0000000000..8e45b08935 --- /dev/null +++ b/console/ui/src/shared/ui/error-box/ui/index.tsx @@ -0,0 +1,16 @@ +import { FC } from 'react'; +import { Box, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { ErrorBoxProps } from '@shared/ui/error-box/model/types.ts'; + +const ErrorBox: FC = ({ text }) => { + const { t } = useTranslation('shared'); + + return ( + + {text ?? t('somethingWentWrongWhileRendering')} + + ); +}; + +export default ErrorBox; diff --git a/console/ui/src/shared/ui/info-card-body/ui/index.tsx b/console/ui/src/shared/ui/info-card-body/ui/index.tsx index dfe7e5fed6..27b6d86be7 100644 --- a/console/ui/src/shared/ui/info-card-body/ui/index.tsx +++ b/console/ui/src/shared/ui/info-card-body/ui/index.tsx @@ -8,20 +8,18 @@ import { InfoCardBodyProps } from '@shared/ui/info-card-body/model/types.ts'; * @param config - Config with data to render. * @constructor */ -const InfoCardBody: FC = ({ config }) => { - return ( - - {config.map(({ title, children }, index) => ( - - - {title} - - {children} - {index < config.length - 1 ? : null} - - ))} - - ); -}; +const InfoCardBody: FC = ({ config }) => ( + + {config.map(({ title, children }, index) => ( + + + {title} + + {children} + {index < config.length - 1 ? : null} + + ))} + +); export default InfoCardBody; diff --git a/console/ui/src/shared/ui/slider-box/model/types.ts b/console/ui/src/shared/ui/slider-box/model/types.ts index d8af2950ed..cc64815116 100644 --- a/console/ui/src/shared/ui/slider-box/model/types.ts +++ b/console/ui/src/shared/ui/slider-box/model/types.ts @@ -1,4 +1,5 @@ import { ReactElement } from 'react'; +import { Mark } from '@mui/material/Slider/useSlider.types'; export interface SliderBoxProps { amount: number; @@ -14,16 +15,14 @@ export interface SliderBoxProps { error?: object; limitMin?: boolean; limitMax?: boolean; + topRightElements?: ReactElement | null; } -export type GenerateMarkType = (value: number, marksAdditionalLabel: string) => { label: string; value: string }; +export type GenerateMarkType = (value: number, marksAdditionalLabel: string) => { label: string; value: number }; export type GenerateSliderMarksType = ( min: number, max: number, amount: number, marksAdditionalLabel: string, -) => { - label: string; - value: string; -}[]; +) => Mark[]; diff --git a/console/ui/src/shared/ui/slider-box/ui/index.tsx b/console/ui/src/shared/ui/slider-box/ui/index.tsx index 720f327548..eed4034e42 100644 --- a/console/ui/src/shared/ui/slider-box/ui/index.tsx +++ b/console/ui/src/shared/ui/slider-box/ui/index.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react'; +import { ChangeEvent, FC } from 'react'; import { Box, Slider, TextField, Typography, useTheme } from '@mui/material'; import { SliderBoxProps } from '@shared/ui/slider-box/model/types.ts'; @@ -18,10 +18,11 @@ const ClusterSliderBox: FC = ({ error, limitMin = true, limitMax, + topRightElements, }) => { const theme = useTheme(); - const onChange = (e: React.ChangeEvent) => { + const onChange = (e: ChangeEvent) => { const { value } = e.target; if (/^\d*$/.test(value)) { const num = Number(value); @@ -30,13 +31,18 @@ const ClusterSliderBox: FC = ({ }; return ( - + = ({ error={!!error} helperText={(error as any)?.message ?? ''} size="small" - sx={{ width: '100px' }} + sx={{ width: '75px' }} /> {unit} - + + {topRightElements ?? null} = ({ valueLabelDisplay="auto" min={min} max={max} - marks={(marks ?? generateSliderMarks(min ?? 1, max ?? 100, marksAmount ?? 0, marksAdditionalLabel)) as any} + marks={marks ?? generateSliderMarks(min ?? 1, max ?? 100, marksAmount ?? 0, marksAdditionalLabel)} /> diff --git a/console/ui/src/widgets/cluster-form/model/constants.ts b/console/ui/src/widgets/cluster-form/model/constants.ts index 0c7f51b36e..26f3dae016 100644 --- a/console/ui/src/widgets/cluster-form/model/constants.ts +++ b/console/ui/src/widgets/cluster-form/model/constants.ts @@ -1,28 +1,57 @@ -export const numberOfInstances = [1, 3, 7, 15, 32]; -export const dataDiskStorage = [10, 100, 500, 1000, 2000, 16000]; +import { AUTHENTICATION_METHODS, IS_EXPERT_MODE } from '@shared/model/constants.ts'; +import { BACKUP_METHODS, BACKUPS_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/backups-block/model/const.ts'; +import { ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/additional-settings-block/model/const.ts'; +import { POSTGRES_PARAMETERS_FIELD_NAMES } from '@entities/cluster/expert-mode/postgres-parameters-block/model/const.ts'; +import { KERNEL_PARAMETERS_FIELD_NAMES } from '@entities/cluster/expert-mode/kernel-parameters-block/model/const.ts'; +import { DATABASES_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/databases-block/model/const.ts'; +import { + CONNECTION_POOLS_BLOCK_FIELD_NAMES, + POOL_MODES, +} from '@entities/cluster/expert-mode/connection-pools-block/model/const.ts'; +import { INSTANCES_AMOUNT_BLOCK_VALUES } from '@entities/cluster/instances-amount-block/model/const.ts'; +import { STORAGE_BLOCK_FIELDS } from '@entities/cluster/storage-block/model/const.ts'; +import { EXTENSION_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/extensions-block/model/const.ts'; +import { NETWORK_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/network-block/model/const.ts'; +import { INSTANCES_BLOCK_FIELD_NAMES } from '@entities/cluster/instances-block/model/const.ts'; +import { + LOAD_BALANCERS_DATABASES_DEFAULT_VALUES, + LOAD_BALANCERS_FIELD_NAMES, +} from '@entities/cluster/load-balancers-block/model/const.ts'; +import { + DCS_BLOCK_FIELD_NAMES, + DCS_DATABASES_DEFAULT_VALUES, + DCS_TYPES, +} from '@entities/cluster/expert-mode/dcs-block/model/const.ts'; +import { DATA_DIRECTORY_FIELD_NAMES } from '@entities/cluster/expert-mode/data-directory-block/model/const.ts'; +import { DATABASE_SERVERS_FIELD_NAMES } from '@entities/cluster/database-servers-block/model/const.ts'; +import { uniqueId } from 'lodash'; + +export const CLUSTER_CREATION_TYPES = Object.freeze({ + FORM: 'form', + YAML: 'yaml', +}); const CLUSTER_CLOUD_PROVIDER_FIELD_NAMES = Object.freeze({ REGION: 'region', REGION_CONFIG: 'regionConfig', INSTANCE_TYPE: 'instanceType', INSTANCE_CONFIG: 'instanceConfig', - INSTANCES_AMOUNT: 'instancesAmount', - STORAGE_AMOUNT: 'storageAmount', SSH_PUBLIC_KEY: 'sshPublicKey', + ...INSTANCES_AMOUNT_BLOCK_VALUES, + ...STORAGE_BLOCK_FIELDS, }); const CLUSTER_LOCAL_MACHINE_FIELD_NAMES = Object.freeze({ - DATABASE_SERVERS: 'databaseServers', EXISTING_CLUSTER: 'existingCluster', - HOSTNAME: 'hostname', - IP_ADDRESS: 'ipAddress', - LOCATION: 'location', AUTHENTICATION_METHOD: 'authenticationMethod', SECRET_KEY_NAME: 'secretKeyName', AUTHENTICATION_IS_SAVE_TO_CONSOLE: 'authenticationSaveToConsole', CLUSTER_VIP_ADDRESS: 'clusterVIPAddress', - IS_HAPROXY_LOAD_BALANCER: 'isHaproxyLoadBalancer', IS_USE_DEFINED_SECRET: 'isUseDefinedSecret', + ...LOAD_BALANCERS_FIELD_NAMES, + ...DATABASE_SERVERS_FIELD_NAMES, + ...DCS_BLOCK_FIELD_NAMES, + ...DATA_DIRECTORY_FIELD_NAMES, }); export const CLUSTER_FORM_FIELD_NAMES = Object.freeze({ @@ -32,6 +61,95 @@ export const CLUSTER_FORM_FIELD_NAMES = Object.freeze({ DESCRIPTION: 'description', POSTGRES_VERSION: 'postgresVersion', SECRET_ID: 'secretId', + CREATION_TYPE: 'creationType', ...CLUSTER_CLOUD_PROVIDER_FIELD_NAMES, ...CLUSTER_LOCAL_MACHINE_FIELD_NAMES, + ...DATABASES_BLOCK_FIELD_NAMES, + ...CONNECTION_POOLS_BLOCK_FIELD_NAMES, + ...EXTENSION_BLOCK_FIELD_NAMES, + ...BACKUPS_BLOCK_FIELD_NAMES, + ...POSTGRES_PARAMETERS_FIELD_NAMES, + ...KERNEL_PARAMETERS_FIELD_NAMES, +}); + +export const CLOUD_CLUSTER_DEFAULT_VALUES = Object.freeze({ + [INSTANCES_AMOUNT_BLOCK_VALUES.INSTANCES_AMOUNT]: 3, + [INSTANCES_BLOCK_FIELD_NAMES.INSTANCE_TYPE]: 'small', + [STORAGE_BLOCK_FIELDS.STORAGE_AMOUNT]: 100, + ...(IS_EXPERT_MODE + ? { + [INSTANCES_BLOCK_FIELD_NAMES.SERVER_TYPE]: '', + [NETWORK_BLOCK_FIELD_NAMES.SERVER_NETWORK]: '', + [INSTANCES_AMOUNT_BLOCK_VALUES.IS_SPOT_INSTANCES]: false, + [STORAGE_BLOCK_FIELDS.FILE_SYSTEM_TYPE]: 'ext4', + [ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.IS_CLOUD_LOAD_BALANCER]: true, + } + : {}), +}); + +export const LOCAL_CLUSTER_DEFAULT_VALUES = Object.freeze({ + [CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD]: AUTHENTICATION_METHODS.SSH, + ...(IS_EXPERT_MODE + ? { + [DCS_BLOCK_FIELD_NAMES.TYPE]: DCS_TYPES.ETCD, + [DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_NEW_CLUSTER]: true, + [DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_TO_DB_SERVERS]: true, + [DCS_BLOCK_FIELD_NAMES.DCS_DATABASES]: Array(3) + .fill(0) + .map(() => DCS_DATABASES_DEFAULT_VALUES), + [LOAD_BALANCERS_FIELD_NAMES.IS_DEPLOY_TO_DATABASE_SERVERS]: false, + [LOAD_BALANCERS_FIELD_NAMES.DATABASES]: [LOAD_BALANCERS_DATABASES_DEFAULT_VALUES], + [DATA_DIRECTORY_FIELD_NAMES.DATA_DIRECTORY]: '/pgdata/18/main', + } + : {}), +}); + +export const CLUSTER_FORM_DEFAULT_VALUES = Object.freeze({ + ...CLOUD_CLUSTER_DEFAULT_VALUES, + ...LOCAL_CLUSTER_DEFAULT_VALUES, + [CLUSTER_FORM_FIELD_NAMES.DESCRIPTION]: '', + [CLUSTER_FORM_FIELD_NAMES.CREATION_TYPE]: CLUSTER_CREATION_TYPES.FORM, + [CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET]: false, + [CLUSTER_FORM_FIELD_NAMES.SECRET_ID]: '', + [DATABASE_SERVERS_FIELD_NAMES.DATABASE_SERVERS]: Array(3) + .fill(0) + .map(() => ({ + [DATABASE_SERVERS_FIELD_NAMES.DATABASE_HOSTNAME]: '', + [DATABASE_SERVERS_FIELD_NAMES.DATABASE_IP_ADDRESS]: '', + [DATABASE_SERVERS_FIELD_NAMES.DATABASE_LOCATION]: '', + })), + ...(IS_EXPERT_MODE + ? { + [BACKUPS_BLOCK_FIELD_NAMES.IS_BACKUPS_ENABLED]: true, + [BACKUPS_BLOCK_FIELD_NAMES.BACKUP_METHOD]: BACKUP_METHODS.PG_BACK_REST, + [BACKUPS_BLOCK_FIELD_NAMES.BACKUP_RETENTION]: 30, + [BACKUPS_BLOCK_FIELD_NAMES.BACKUP_START_TIME]: 1, + [BACKUPS_BLOCK_FIELD_NAMES.CONFIG]: '', + [BACKUPS_BLOCK_FIELD_NAMES.ACCESS_KEY]: '', + [BACKUPS_BLOCK_FIELD_NAMES.SECRET_KEY]: '', + [POSTGRES_PARAMETERS_FIELD_NAMES.POSTGRES_PARAMETERS]: '', + [KERNEL_PARAMETERS_FIELD_NAMES.KERNEL_PARAMETERS]: '', + [DATABASES_BLOCK_FIELD_NAMES.DATABASES]: [ + { + [DATABASES_BLOCK_FIELD_NAMES.DATABASE_NAME]: 'db1', + [DATABASES_BLOCK_FIELD_NAMES.USER_NAME]: 'db1-user', + [DATABASES_BLOCK_FIELD_NAMES.USER_PASSWORD]: '', + [DATABASES_BLOCK_FIELD_NAMES.ENCODING]: 'UTF8', + [DATABASES_BLOCK_FIELD_NAMES.LOCALE]: 'en_US.UTF-8', + [DATABASES_BLOCK_FIELD_NAMES.BLOCK_ID]: uniqueId(), + }, + ], + [CONNECTION_POOLS_BLOCK_FIELD_NAMES.IS_CONNECTION_POOLER_ENABLED]: true, + [CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOLS]: [ + { + [CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOL_NAME]: 'db1', + [CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOL_SIZE]: 20, + [CONNECTION_POOLS_BLOCK_FIELD_NAMES.POOL_MODE]: POOL_MODES[0].option, + }, + ], + [EXTENSION_BLOCK_FIELD_NAMES.EXTENSIONS]: {}, + [ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.SYNC_STANDBY_NODES]: 0, + [ADDITIONAL_SETTINGS_BLOCK_FIELD_NAMES.IS_NETDATA_MONITORING]: true, + } + : {}), }); diff --git a/console/ui/src/widgets/cluster-form/model/types.ts b/console/ui/src/widgets/cluster-form/model/types.ts index f0f9afaa33..3f508f6f2f 100644 --- a/console/ui/src/widgets/cluster-form/model/types.ts +++ b/console/ui/src/widgets/cluster-form/model/types.ts @@ -1,13 +1,15 @@ -import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; +import { ResponseDeploymentInfo } from '@shared/api/api/deployments.ts'; +import { ResponseEnvironment } from '@shared/api/api/environments.ts'; +import { ResponsePostgresVersion } from '@shared/api/api/other.ts'; + +export interface ClusterFormProps { + deploymentsData?: ResponseDeploymentInfo[]; + environmentsData?: ResponseEnvironment[]; + postgresVersionsData?: ResponsePostgresVersion[]; +} export interface ClusterFormRegionConfigBoxProps { name: string; place: string; isActive: boolean; } - -export interface ClusterDatabaseServer { - [CLUSTER_FORM_FIELD_NAMES.HOSTNAME]: string; - [CLUSTER_FORM_FIELD_NAMES.IP_ADDRESS]: string; - [CLUSTER_FORM_FIELD_NAMES.LOCATION]: string; -} diff --git a/console/ui/src/widgets/cluster-form/model/validation.ts b/console/ui/src/widgets/cluster-form/model/validation.ts index 7f2156eab2..8dafd7f9b1 100644 --- a/console/ui/src/widgets/cluster-form/model/validation.ts +++ b/console/ui/src/widgets/cluster-form/model/validation.ts @@ -2,12 +2,22 @@ import { TFunction } from 'i18next'; import * as yup from 'yup'; import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; import { PROVIDERS } from '@shared/config/constants.ts'; -import { AUTHENTICATION_METHODS } from '@shared/model/constants.ts'; +import { AUTHENTICATION_METHODS, IS_EXPERT_MODE } from '@shared/model/constants.ts'; import ipRegex from 'ip-regex'; import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; +import { BackupsBlockFormSchema } from '@entities/cluster/expert-mode/backups-block/model/validation.ts'; +import { DatabasesBlockSchema } from '@entities/cluster/expert-mode/databases-block/model/validation.ts'; +import { ConnectionPoolsBlockSchema } from '@entities/cluster/expert-mode/connection-pools-block/model/validation.ts'; +import { PostgresParametersBlockFormSchema } from '@entities/cluster/expert-mode/postgres-parameters-block/model/validation.ts'; +import { KernelParametersBlockFormSchema } from '@entities/cluster/expert-mode/kernel-parameters-block/model/validation.ts'; +import { AdditionalSettingsBlockFormSchema } from '@entities/cluster/expert-mode/additional-settings-block/model/validation.ts'; +import { INSTANCES_BLOCK_FIELD_NAMES } from '@entities/cluster/instances-block/model/const.ts'; +import { STORAGE_BLOCK_FIELDS } from '@entities/cluster/storage-block/model/const.ts'; +import { databaseServersBlockValidation } from '@entities/cluster/database-servers-block/model/validation.ts'; +import { SSH_KEY_BLOCK_FIELD_NAMES } from '@entities/cluster/ssh-key-block/model/const.ts'; -const cloudFormSchema = (t: TFunction) => - yup.object({ +const CloudFormSchema = (t: TFunction) => { + const defaultClusterFormSchema = yup.object({ [CLUSTER_FORM_FIELD_NAMES.REGION]: yup .mixed() .when(CLUSTER_FORM_FIELD_NAMES.PROVIDER, ([provider], schema) => @@ -25,7 +35,7 @@ const cloudFormSchema = (t: TFunction) => .required() : schema.notRequired(), ), - [CLUSTER_FORM_FIELD_NAMES.INSTANCE_TYPE]: yup + [INSTANCES_BLOCK_FIELD_NAMES.INSTANCE_TYPE]: yup .mixed() .when(CLUSTER_FORM_FIELD_NAMES.PROVIDER, ([provider], schema) => provider?.code !== PROVIDERS.LOCAL ? yup.string().required() : schema.notRequired(), @@ -52,12 +62,12 @@ const cloudFormSchema = (t: TFunction) => .when(CLUSTER_FORM_FIELD_NAMES.PROVIDER, ([provider], schema) => provider?.code !== PROVIDERS.LOCAL ? yup.number().required() : schema.notRequired(), ), - [CLUSTER_FORM_FIELD_NAMES.STORAGE_AMOUNT]: yup + [STORAGE_BLOCK_FIELDS.STORAGE_AMOUNT]: yup .mixed() .when(CLUSTER_FORM_FIELD_NAMES.PROVIDER, ([provider], schema) => provider?.code !== PROVIDERS.LOCAL ? yup.number().required() : schema.notRequired(), ), - [CLUSTER_FORM_FIELD_NAMES.SSH_PUBLIC_KEY]: yup + [SSH_KEY_BLOCK_FIELD_NAMES.SSH_PUBLIC_KEY]: yup .mixed() .when(CLUSTER_FORM_FIELD_NAMES.PROVIDER, ([provider], schema) => provider?.code !== PROVIDERS.LOCAL @@ -66,128 +76,113 @@ const cloudFormSchema = (t: TFunction) => ), }); -export const localFormSchema = (t: TFunction) => - yup.object({ - [CLUSTER_FORM_FIELD_NAMES.DATABASE_SERVERS]: yup - .mixed() - .when(CLUSTER_FORM_FIELD_NAMES.PROVIDER, ([provider], schema) => - provider?.code === PROVIDERS.LOCAL - ? yup.array( - yup.object({ - [CLUSTER_FORM_FIELD_NAMES.HOSTNAME]: yup.string().required(t('requiredField', { ns: 'validation' })), - [CLUSTER_FORM_FIELD_NAMES.IP_ADDRESS]: yup - .string() - .required(t('requiredField', { ns: 'validation' })) - .test('should be a correct IP', t('shouldBeACorrectV4Ip', { ns: 'validation' }), (value) => - ipRegex.v4({ exact: true }).test(value), - ), - [CLUSTER_FORM_FIELD_NAMES.LOCATION]: yup.string(), - }), - ) - : schema.notRequired(), - ), - [CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD]: yup - .mixed() - .when(CLUSTER_FORM_FIELD_NAMES.PROVIDER, ([provider], schema) => - provider?.code === PROVIDERS.LOCAL ? yup.string().required() : schema.notRequired(), - ), - [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SSH_PRIVATE_KEY]: yup - .mixed() - .when( - [CLUSTER_FORM_FIELD_NAMES.PROVIDER, CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD], - ([provider, authenticationMethod], schema) => - provider?.code === PROVIDERS.LOCAL && authenticationMethod === AUTHENTICATION_METHODS.SSH - ? yup - .mixed() - .when(CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET, ([isUseDefinedSecret], schema) => - !isUseDefinedSecret - ? yup.string().required(t('requiredField', { ns: 'validation' })) - : schema.notRequired(), - ) - : schema.notRequired(), - ), - [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.USERNAME]: yup - .mixed() - .when( - [CLUSTER_FORM_FIELD_NAMES.PROVIDER, CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD], - ([provider, authenticationMethod], schema) => - provider?.code === PROVIDERS.LOCAL && authenticationMethod === AUTHENTICATION_METHODS.PASSWORD - ? yup - .mixed() - .when(CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET, ([isUseDefinedSecret], schema) => - !isUseDefinedSecret - ? yup.string().required(t('requiredField', { ns: 'validation' })) - : schema.notRequired(), - ) - : schema.notRequired(), - ), - [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.PASSWORD]: yup - .mixed() - .when( - [CLUSTER_FORM_FIELD_NAMES.PROVIDER, CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD], - ([provider, authenticationMethod], schema) => - provider?.code === PROVIDERS.LOCAL && authenticationMethod === AUTHENTICATION_METHODS.PASSWORD + return IS_EXPERT_MODE ? defaultClusterFormSchema : defaultClusterFormSchema; +}; + +export const LocalFormSchema = (t: TFunction) => { + const defaultLocalFormSchema = yup + .object({ + [CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD]: yup + .mixed() + .when(CLUSTER_FORM_FIELD_NAMES.PROVIDER, ([provider], schema) => + provider?.code === PROVIDERS.LOCAL ? yup.string().required() : schema.notRequired(), + ), + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SSH_PRIVATE_KEY]: yup + .mixed() + .when( + [CLUSTER_FORM_FIELD_NAMES.PROVIDER, CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD], + ([provider, authenticationMethod], schema) => + provider?.code === PROVIDERS.LOCAL && authenticationMethod === AUTHENTICATION_METHODS.SSH + ? yup + .mixed() + .when(CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET, ([isUseDefinedSecret], schema) => + !isUseDefinedSecret + ? yup.string().required(t('requiredField', { ns: 'validation' })) + : schema.notRequired(), + ) + : schema.notRequired(), + ), + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.USERNAME]: yup + .mixed() + .when( + [CLUSTER_FORM_FIELD_NAMES.PROVIDER, CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD], + ([provider, authenticationMethod], schema) => + provider?.code === PROVIDERS.LOCAL && authenticationMethod === AUTHENTICATION_METHODS.PASSWORD + ? yup + .mixed() + .when(CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET, ([isUseDefinedSecret], schema) => + !isUseDefinedSecret + ? yup.string().required(t('requiredField', { ns: 'validation' })) + : schema.notRequired(), + ) + : schema.notRequired(), + ), + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.PASSWORD]: yup + .mixed() + .when( + [CLUSTER_FORM_FIELD_NAMES.PROVIDER, CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD], + ([provider, authenticationMethod], schema) => + provider?.code === PROVIDERS.LOCAL && authenticationMethod === AUTHENTICATION_METHODS.PASSWORD + ? yup + .mixed() + .when(CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET, ([isUseDefinedSecret], schema) => + !isUseDefinedSecret + ? yup.string().required(t('requiredField', { ns: 'validation' })) + : schema.notRequired(), + ) + : schema.notRequired(), + ), + [CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_IS_SAVE_TO_CONSOLE]: yup + .mixed() + .when( + [CLUSTER_FORM_FIELD_NAMES.PROVIDER, CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET], + ([provider, isUseDefinedSecret], schema) => + provider?.code === PROVIDERS.LOCAL && !isUseDefinedSecret ? yup.boolean() : schema.notRequired(), + ), + [CLUSTER_FORM_FIELD_NAMES.SECRET_KEY_NAME]: yup + .mixed() + .when( + [CLUSTER_FORM_FIELD_NAMES.PROVIDER, CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_IS_SAVE_TO_CONSOLE], + ([provider, isSaveToConsole], schema) => + provider?.code === PROVIDERS.LOCAL && isSaveToConsole + ? yup.string().required(t('requiredField', { ns: 'validation' })) + : schema.notRequired(), + ), + [CLUSTER_FORM_FIELD_NAMES.CLUSTER_VIP_ADDRESS]: yup + .mixed() + .when(CLUSTER_FORM_FIELD_NAMES.PROVIDER, ([provider], schema) => + provider?.code === PROVIDERS.LOCAL ? yup - .mixed() - .when(CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET, ([isUseDefinedSecret], schema) => - !isUseDefinedSecret - ? yup.string().required(t('requiredField', { ns: 'validation' })) - : schema.notRequired(), + .string() + .test( + 'should be a correct VIP address', + t('shouldBeACorrectV4Ip', { ns: 'validation' }), + (value) => !value || ipRegex.v4({ exact: true }).test(value), ) : schema.notRequired(), - ), - [CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_IS_SAVE_TO_CONSOLE]: yup - .mixed() - .when( - [CLUSTER_FORM_FIELD_NAMES.PROVIDER, CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET], - ([provider, isUseDefinedSecret], schema) => - provider?.code === PROVIDERS.LOCAL && !isUseDefinedSecret ? yup.boolean() : schema.notRequired(), - ), - [CLUSTER_FORM_FIELD_NAMES.SECRET_KEY_NAME]: yup - .mixed() - .when( - [CLUSTER_FORM_FIELD_NAMES.PROVIDER, CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_IS_SAVE_TO_CONSOLE], - ([provider, isSaveToConsole], schema) => - provider?.code === PROVIDERS.LOCAL && isSaveToConsole - ? yup.string().required(t('requiredField', { ns: 'validation' })) - : schema.notRequired(), - ), - [CLUSTER_FORM_FIELD_NAMES.CLUSTER_VIP_ADDRESS]: yup - .mixed() - .when(CLUSTER_FORM_FIELD_NAMES.PROVIDER, ([provider], schema) => - provider?.code === PROVIDERS.LOCAL - ? yup - .string() - .test( - 'should be a correct VIP address', - t('shouldBeACorrectV4Ip', { ns: 'validation' }), - (value) => !value || ipRegex.v4({ exact: true }).test(value), - ) - : schema.notRequired(), - ), - [CLUSTER_FORM_FIELD_NAMES.IS_HAPROXY_LOAD_BALANCER]: yup - .mixed() - .when(CLUSTER_FORM_FIELD_NAMES.PROVIDER, ([provider], schema) => - provider?.code === PROVIDERS.LOCAL ? yup.boolean() : schema.notRequired(), - ), - [CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET]: yup - .mixed() - .when([CLUSTER_FORM_FIELD_NAMES.PROVIDER], ([provider], schema) => - provider?.code === PROVIDERS.LOCAL ? yup.boolean().optional() : schema.notRequired(), - ), - [CLUSTER_FORM_FIELD_NAMES.SECRET_ID]: yup - .mixed() - .when( - [CLUSTER_FORM_FIELD_NAMES.PROVIDER, CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET], - ([provider, isUseDefinedSecret], schema) => - provider?.code === PROVIDERS.LOCAL && isUseDefinedSecret - ? yup.string().required(t('requiredField', { ns: 'validation' })) - : schema.notRequired(), - ), - }); + ), + [CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET]: yup + .mixed() + .when([CLUSTER_FORM_FIELD_NAMES.PROVIDER], ([provider], schema) => + provider?.code === PROVIDERS.LOCAL ? yup.boolean().optional() : schema.notRequired(), + ), + [CLUSTER_FORM_FIELD_NAMES.SECRET_ID]: yup + .mixed() + .when( + [CLUSTER_FORM_FIELD_NAMES.PROVIDER, CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET], + ([provider, isUseDefinedSecret], schema) => + provider?.code === PROVIDERS.LOCAL && isUseDefinedSecret + ? yup.string().required(t('requiredField', { ns: 'validation' })) + : schema.notRequired(), + ), + }) + .concat(databaseServersBlockValidation(t)); -export const ClusterFormSchema = (t: TFunction) => - yup + return IS_EXPERT_MODE ? defaultLocalFormSchema : defaultLocalFormSchema; +}; + +export const ClusterFormSchema = (t: TFunction) => { + const defaultSchema = yup .object({ [CLUSTER_FORM_FIELD_NAMES.PROVIDER]: yup.object().required(), [CLUSTER_FORM_FIELD_NAMES.ENVIRONMENT_ID]: yup.number(), @@ -200,5 +195,16 @@ export const ClusterFormSchema = (t: TFunction) => [CLUSTER_FORM_FIELD_NAMES.DESCRIPTION]: yup.string(), [CLUSTER_FORM_FIELD_NAMES.POSTGRES_VERSION]: yup.number().required(t('requiredField', { ns: 'validation' })), }) - .concat(cloudFormSchema(t)) - .concat(localFormSchema(t)); + .concat(CloudFormSchema(t)) + .concat(LocalFormSchema(t)); + + return IS_EXPERT_MODE + ? defaultSchema + .concat(DatabasesBlockSchema) + .concat(ConnectionPoolsBlockSchema(t)) + .concat(BackupsBlockFormSchema(t)) + .concat(PostgresParametersBlockFormSchema(t)) + .concat(KernelParametersBlockFormSchema(t)) + .concat(AdditionalSettingsBlockFormSchema(t)) + : defaultSchema; +}; diff --git a/console/ui/src/widgets/cluster-form/ui/ClusterFormCloudProviderFormPart.tsx b/console/ui/src/widgets/cluster-form/ui/ClusterFormCloudProviderFormPart.tsx index 32fe3e0264..07638aa7e0 100644 --- a/console/ui/src/widgets/cluster-form/ui/ClusterFormCloudProviderFormPart.tsx +++ b/console/ui/src/widgets/cluster-form/ui/ClusterFormCloudProviderFormPart.tsx @@ -1,9 +1,12 @@ -import { FC } from 'react'; -import ClusterFormRegionBlock from '@entities/cluster-form-cloud-region-block'; -import ClusterFormInstancesBlock from '@entities/cluster-form-instances-block'; -import InstancesAmountBlock from '@entities/cluster-form-instances-amount-block'; -import StorageBlock from '@entities/storage-block'; -import ClusterFormSshKeyBlock from '@entities/ssh-key-block'; +import { FC, lazy } from 'react'; +import ClusterFormRegionBlock from '@entities/cluster/cloud-region-block'; +import ClusterFormInstancesBlock from '@entities/cluster/instances-block'; +import InstancesAmountBlock from '@entities/cluster/instances-amount-block'; +import StorageBlock from '@entities/cluster/storage-block'; +import ClusterFormSshKeyBlock from '@entities/cluster/ssh-key-block'; +import { IS_EXPERT_MODE } from '@shared/model/constants.ts'; + +const NetworkBlock = lazy(() => import('@entities/cluster/expert-mode/network-block/ui')); const ClusterFormCloudProviderFormPart: FC = () => ( <> @@ -11,6 +14,7 @@ const ClusterFormCloudProviderFormPart: FC = () => ( + {IS_EXPERT_MODE ? : null} ); diff --git a/console/ui/src/widgets/cluster-form/ui/ClusterFormLocalMachineFormPart.tsx b/console/ui/src/widgets/cluster-form/ui/ClusterFormLocalMachineFormPart.tsx index 5ae5dcc61d..9b07c72134 100644 --- a/console/ui/src/widgets/cluster-form/ui/ClusterFormLocalMachineFormPart.tsx +++ b/console/ui/src/widgets/cluster-form/ui/ClusterFormLocalMachineFormPart.tsx @@ -1,12 +1,16 @@ -import { FC } from 'react'; -import DatabaseServersBlock from '@entities/database-servers-block'; +import { FC, lazy } from 'react'; +import DatabaseServersBlock from '@entities/cluster/database-servers-block'; import AuthenticationMethodFormBlock from '@entities/authentification-method-form-block'; -import VipAddressBlock from '@entities/vip-address-block'; -import LoadBalancersBlock from '@entities/load-balancers-block'; +import VipAddressBlock from '@entities/cluster/vip-address-block'; +import LoadBalancersBlock from '@entities/cluster/load-balancers-block'; +import { IS_EXPERT_MODE } from '@shared/model/constants.ts'; + +const DcsBlock = lazy(() => import('@entities/cluster/expert-mode/dcs-block/ui')); const ClusterFormLocalMachineFormPart: FC = () => ( <> + {IS_EXPERT_MODE ? : null} diff --git a/console/ui/src/widgets/cluster-form/ui/index.tsx b/console/ui/src/widgets/cluster-form/ui/index.tsx index a6669a9434..d592603146 100644 --- a/console/ui/src/widgets/cluster-form/ui/index.tsx +++ b/console/ui/src/widgets/cluster-form/ui/index.tsx @@ -1,103 +1,63 @@ -import React, { useLayoutEffect, useRef, useState } from 'react'; -import ProvidersBlock from '@entities/providers-block'; -import ClusterFormEnvironmentBlock from '@entities/cluster-form-environment-block'; -import ClusterNameBox from '@entities/cluster-form-cluster-name-block'; -import ClusterDescriptionBlock from '@entities/cluster-description-block'; -import PostgresVersionBox from '@entities/postgres-version-block'; +import React, { lazy, useRef } from 'react'; +import ClusterFormProvidersBlock from '@entities/cluster/providers-block'; +import ClusterFormEnvironmentBlock from '@entities/cluster/environment-block'; +import ClusterNameBox from '@entities/cluster/cluster-name-block'; +import ClusterDescriptionBlock from '@entities/cluster/description-block'; +import PostgresVersionBox from '@entities/cluster/postgres-version-block'; import DefaultFormButtons from '@shared/ui/default-form-buttons'; -import { FormProvider, useForm } from 'react-hook-form'; +import { useFormContext, useWatch } from 'react-hook-form'; import { generateAbsoluteRouterPath, handleRequestErrorCatch } from '@shared/lib/functions.ts'; import RouterPaths from '@app/router/routerPathsConfig'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import { useGetExternalDeploymentsQuery } from '@shared/api/api/deployments.ts'; import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; import { PROVIDERS } from '@shared/config/constants.ts'; import ClusterFormCloudProviderFormPart from '@widgets/cluster-form/ui/ClusterFormCloudProviderFormPart.tsx'; import ClusterFormLocalMachineFormPart from '@widgets/cluster-form/ui/ClusterFormLocalMachineFormPart.tsx'; -import { useGetClustersDefaultNameQuery, usePostClustersMutation } from '@shared/api/api/clusters.ts'; +import { usePostClustersMutation } from '@shared/api/api/clusters.ts'; import { useAppSelector } from '@app/redux/store/hooks.ts'; import { selectCurrentProject } from '@app/redux/slices/projectSlice/projectSelectors.ts'; import { Stack } from '@mui/material'; -import { yupResolver } from '@hookform/resolvers/yup'; -import { ClusterFormSchema } from '@widgets/cluster-form/model/validation.ts'; -import ClusterSummary from '@widgets/cluster-summary'; import ClusterSecretModal from '@features/cluster-secret-modal'; -import { useGetPostgresVersionsQuery } from '@shared/api/api/other.ts'; -import { useGetEnvironmentsQuery } from '@shared/api/api/environments.ts'; -import { mapFormValuesToRequestFields } from '@features/cluster-secret-modal/lib/functions.ts'; import { toast } from 'react-toastify'; -import { AUTHENTICATION_METHODS } from '@shared/model/constants.ts'; +import { IS_EXPERT_MODE } from '@shared/model/constants.ts'; import { ClusterFormValues } from '@features/cluster-secret-modal/model/types.ts'; import { useGetSecretsQuery, usePostSecretsMutation } from '@shared/api/api/secrets.ts'; import { getSecretBodyFromValues } from '@entities/secret-form-block/lib/functions.ts'; import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; -import Spinner from '@shared/ui/spinner'; - -const ClusterForm: React.FC = () => { +import { ClusterFormProps } from '@widgets/cluster-form/model/types.ts'; +import { DATABASE_SERVERS_FIELD_NAMES } from '@/entities/cluster/database-servers-block/model/const'; +import { mapFormValuesToRequestFields } from '@shared/lib/clusterValuesTransformFunctions.ts'; + +const DatabaseBlock = lazy(() => import('@entities/cluster/expert-mode/databases-block/ui')); +const ConnectionPoolsBlock = lazy(() => import('@entities/cluster/expert-mode/connection-pools-block/ui')); +const ExtensionsBlock = lazy(() => import('@entities/cluster/expert-mode/extensions-block/ui')); +const BackupsBlock = lazy(() => import('@entities/cluster/expert-mode/backups-block/ui')); +const PostgresParametersBlock = lazy(() => import('@entities/cluster/expert-mode/postgres-parameters-block/ui')); +const KernelParametersBlock = lazy(() => import('@entities/cluster/expert-mode/kernel-parameters-block/ui')); +const AdditionalSettingsBlock = lazy(() => import('@entities/cluster/expert-mode/additional-settings-block/ui')); +const DataDirectoryBlock = lazy(() => import('@entities/cluster/expert-mode/data-directory-block/ui')); + +const ClusterForm: React.FC = ({ + deploymentsData = [], + environmentsData = [], + postgresVersionsData = [], +}) => { const { t } = useTranslation(['clusters', 'validation', 'toasts']); const navigate = useNavigate(); const createSecretResultRef = useRef(null); // ref is used for case when user saves secret and uses its ID to create cluster - const [isResetting, setIsResetting] = useState(false); - const currentProject = useAppSelector(selectCurrentProject); const [addSecretTrigger, addSecretTriggerState] = usePostSecretsMutation(); const [addClusterTrigger, addClusterTriggerState] = usePostClustersMutation(); - const deployments = useGetExternalDeploymentsQuery({ offset: 0, limit: 999_999_999 }); - const environments = useGetEnvironmentsQuery({ offset: 0, limit: 999_999_999 }); - const postgresVersions = useGetPostgresVersionsQuery(); - const clusterName = useGetClustersDefaultNameQuery(); - - const methods = useForm({ - mode: 'all', - resolver: yupResolver(ClusterFormSchema(t)), - defaultValues: { - [CLUSTER_FORM_FIELD_NAMES.INSTANCES_AMOUNT]: 3, - [CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD]: AUTHENTICATION_METHODS.SSH, - [CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET]: false, - [CLUSTER_FORM_FIELD_NAMES.SECRET_ID]: '', - [CLUSTER_FORM_FIELD_NAMES.DATABASE_SERVERS]: Array(3) - .fill(0) - .map(() => ({ - [CLUSTER_FORM_FIELD_NAMES.HOSTNAME]: '', - [CLUSTER_FORM_FIELD_NAMES.IP_ADDRESS]: '', - [CLUSTER_FORM_FIELD_NAMES.LOCATION]: '', - })), - }, - }); + const methods = useFormContext(); - const watchProvider = methods.watch(CLUSTER_FORM_FIELD_NAMES.PROVIDER); + const watchProvider = useWatch({ name: CLUSTER_FORM_FIELD_NAMES.PROVIDER }); const secrets = useGetSecretsQuery({ type: watchProvider?.code, projectId: currentProject }); - useLayoutEffect(() => { - if (deployments.isFetching || postgresVersions.isFetching || environments.isFetching || clusterName.isFetching) - setIsResetting(true); - if (deployments.data?.data && postgresVersions.data?.data && environments.data?.data && clusterName.data) { - // eslint-disable-next-line @typescript-eslint/require-await - const resetForm = async () => { - // sync function will result in form values setting error - const providers = deployments.data.data; - methods.reset((values) => ({ - ...values, - [CLUSTER_FORM_FIELD_NAMES.PROVIDER]: providers?.[0], - [CLUSTER_FORM_FIELD_NAMES.REGION]: providers?.[0]?.cloud_regions[0]?.code, - [CLUSTER_FORM_FIELD_NAMES.REGION_CONFIG]: providers?.[0]?.cloud_regions[0]?.datacenters?.[0], - [CLUSTER_FORM_FIELD_NAMES.INSTANCE_TYPE]: 'small', - [CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]: providers?.[0]?.instance_types?.small?.[0], - [CLUSTER_FORM_FIELD_NAMES.STORAGE_AMOUNT]: 100, - [CLUSTER_FORM_FIELD_NAMES.POSTGRES_VERSION]: postgresVersions.data?.data?.at(-1)?.major_version, - [CLUSTER_FORM_FIELD_NAMES.ENVIRONMENT_ID]: environments.data?.data?.[0]?.id, - [CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME]: clusterName.data?.name ?? 'postgres-cluster', - })); - }; - void resetForm().then(() => setIsResetting(false)); - } - }, [deployments.data?.data, postgresVersions.data?.data, environments.data?.data, clusterName.data, methods]); - const submitLocalCluster = async (values: ClusterFormValues) => { if (values[CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_IS_SAVE_TO_CONSOLE] && !createSecretResultRef?.current) { createSecretResultRef.current = await addSecretTrigger({ @@ -125,17 +85,6 @@ const ClusterForm: React.FC = () => { projectId: Number(currentProject), }), }).unwrap(); - toast.info( - t( - values[CLUSTER_FORM_FIELD_NAMES.EXISTING_CLUSTER] - ? 'clusterSuccessfullyImported' - : 'clusterSuccessfullyCreated', - { - ns: 'toasts', - clusterName: values[CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME], - } - ) - ); }; const submitCloudCluster = async (values: ClusterFormValues) => { @@ -146,25 +95,24 @@ const ClusterForm: React.FC = () => { projectId: Number(currentProject), }), }).unwrap(); - toast.info( - t( - values[CLUSTER_FORM_FIELD_NAMES.EXISTING_CLUSTER] - ? 'clusterSuccessfullyImported' - : 'clusterSuccessfullyCreated', - { - ns: 'toasts', - clusterName: values[CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME], - } - ) - ); }; const onSubmit = async (values: ClusterFormValues) => { try { - values[CLUSTER_FORM_FIELD_NAMES.PROVIDER].code === PROVIDERS.LOCAL - ? await submitLocalCluster(values) - : await submitCloudCluster(values); - navigate(generateAbsoluteRouterPath(RouterPaths.clusters.absolutePath)); + if (values[CLUSTER_FORM_FIELD_NAMES.PROVIDER].code === PROVIDERS.LOCAL) await submitLocalCluster(values); + else await submitCloudCluster(values); + toast.info( + t( + values[DATABASE_SERVERS_FIELD_NAMES.IS_CLUSTER_EXISTS] + ? 'clusterSuccessfullyImported' + : 'clusterSuccessfullyCreated', + { + ns: 'toasts', + clusterName: values[CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME], + }, + ), + ); + await navigate(generateAbsoluteRouterPath(RouterPaths.clusters.absolutePath)); } catch (e) { handleRequestErrorCatch(e); } @@ -172,48 +120,52 @@ const ClusterForm: React.FC = () => { const cancelHandler = () => navigate(generateAbsoluteRouterPath(RouterPaths.clusters.absolutePath)); - const { isValid, isSubmitting } = methods.formState; // spreading is required by React Hook Form to ensure correct form state - - return isResetting || deployments.isFetching || postgresVersions.isFetching || environments.isFetching ? ( - - ) : ( - - - -
- - - {watchProvider?.code === PROVIDERS.LOCAL ? ( - - ) : ( - - )} - - - - - {watchProvider?.code !== PROVIDERS.LOCAL && secrets?.data?.data?.length !== 1 ? ( - - ) : ( - - )} - -
- + const { isValid, isSubmitting } = methods.formState; // spreading is required by React Hook Form to ensure the correct form state + + return ( + +
+ + + {watchProvider?.code === PROVIDERS.LOCAL ? ( + + ) : ( + + )} + + + + + {IS_EXPERT_MODE && watchProvider?.code === PROVIDERS.LOCAL ? : null} + {IS_EXPERT_MODE ? ( + <> + + + + + + + + + ) : null} + {watchProvider?.code !== PROVIDERS.LOCAL && secrets?.data?.data?.length !== 1 ? ( + + ) : ( + + )} - - +
+
); }; diff --git a/console/ui/src/widgets/cluster-summary/lib/hooks.tsx b/console/ui/src/widgets/cluster-summary/lib/hooks.tsx index cbe4fcd583..d88e2f3c01 100644 --- a/console/ui/src/widgets/cluster-summary/lib/hooks.tsx +++ b/console/ui/src/widgets/cluster-summary/lib/hooks.tsx @@ -15,11 +15,20 @@ import { LocalClustersSummary, UseGetSummaryConfigProps, } from '@widgets/cluster-summary/model/types.ts'; +import { LOAD_BALANCERS_FIELD_NAMES } from '@/entities/cluster/load-balancers-block/model/const'; +import { STORAGE_BLOCK_FIELDS } from '@entities/cluster/storage-block/model/const.ts'; +import { DATABASE_SERVERS_FIELD_NAMES } from '@entities/cluster/database-servers-block/model/const.ts'; +import { INSTANCES_BLOCK_FIELD_NAMES } from '@entities/cluster/instances-block/model/const.ts'; +import { useWatch } from 'react-hook-form'; +import { DCS_BLOCK_FIELD_NAMES } from '@entities/cluster/expert-mode/dcs-block/model/const.ts'; +import { IS_EXPERT_MODE } from '@shared/model/constants.ts'; const useGetCloudProviderConfig = () => { const { t } = useTranslation(['clusters', 'shared']); const theme = useTheme(); + const watchInstanceType = useWatch({ name: INSTANCES_BLOCK_FIELD_NAMES.INSTANCE_TYPE }); + return (data: CloudProviderClustersSummary) => { const defaultVolume = data[CLUSTER_FORM_FIELD_NAMES.PROVIDER]?.volumes?.find((volume) => volume?.is_default) ?? {}; @@ -61,21 +70,30 @@ const useGetCloudProviderConfig = () => { }, { title: t('instanceType'), - children: ( - - {data[CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]?.code} - - - - {data[CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]?.cpu} CPU - - - - {data[CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]?.ram} GB RAM + children: + watchInstanceType !== 'custom' ? ( + + {data[CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]?.code} + + + + {data[CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]?.cpu} CPU + + + + {data[CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]?.ram} GB RAM + - - ), + ) : ( + + {data?.[INSTANCES_BLOCK_FIELD_NAMES.SERVER_TYPE] ?? ''} + + ), }, { title: t('numberOfInstances'), @@ -91,7 +109,7 @@ const useGetCloudProviderConfig = () => { children: ( - {data[CLUSTER_FORM_FIELD_NAMES.STORAGE_AMOUNT]} GB + {data[STORAGE_BLOCK_FIELDS.STORAGE_AMOUNT]} GB ), }, @@ -99,28 +117,34 @@ const useGetCloudProviderConfig = () => { title: `${t('estimatedMonthlyPrice')}*`, children: ( <> - - ~ - {`${data[CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]?.currency}${( - data[CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]?.price_monthly * - data[CLUSTER_FORM_FIELD_NAMES.INSTANCES_AMOUNT] + - defaultVolume?.price_monthly * - data[CLUSTER_FORM_FIELD_NAMES.STORAGE_AMOUNT] * - data[CLUSTER_FORM_FIELD_NAMES.INSTANCES_AMOUNT] - )?.toFixed(2)}/${t('month', { ns: 'shared' })}`} - - - - ~ - {`${data[CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]?.currency}${data[ - CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG - ]?.price_monthly.toFixed(2)}/${t('perServer', { ns: 'clusters' })}`} - , ~ - {`${defaultVolume?.currency}${( - defaultVolume?.price_monthly * data[CLUSTER_FORM_FIELD_NAMES.STORAGE_AMOUNT] - )?.toFixed(2)}/${t('perDisk', { ns: 'clusters' })}`} - - + {watchInstanceType !== 'custom' ? ( + <> + + ~ + {`${data[CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]?.currency}${( + data[CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]?.price_monthly * + data[CLUSTER_FORM_FIELD_NAMES.INSTANCES_AMOUNT] + + defaultVolume?.price_monthly * + data[STORAGE_BLOCK_FIELDS.STORAGE_AMOUNT] * + data[CLUSTER_FORM_FIELD_NAMES.INSTANCES_AMOUNT] + )?.toFixed(2)}/${t('month', { ns: 'shared' })}`} + + + + ~ + {`${data[CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]?.currency}${data[ + CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG + ]?.price_monthly.toFixed(2)}/${t('perServer', { ns: 'clusters' })}`} + , ~ + {`${defaultVolume?.currency}${( + defaultVolume?.price_monthly * data[STORAGE_BLOCK_FIELDS.STORAGE_AMOUNT] + )?.toFixed(2)}/${t('perDisk', { ns: 'clusters' })}`} + + + + ) : ( + N/A + )} { const { t } = useTranslation(['clusters', 'shared']); const theme = useTheme(); + const isHighAvailability = (data: LocalClustersSummary) => { + if ( + (IS_EXPERT_MODE && + !data[DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_NEW_CLUSTER] && + data[DCS_BLOCK_FIELD_NAMES.DCS_DATABASES]?.length >= 3) || + (IS_EXPERT_MODE && + data[DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_NEW_CLUSTER] && + !data[DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_TO_DB_SERVERS] && + data[DCS_BLOCK_FIELD_NAMES.DCS_DATABASES]?.length >= 3) || + (IS_EXPERT_MODE && + data[DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_NEW_CLUSTER] && + data[DCS_BLOCK_FIELD_NAMES.IS_DEPLOY_TO_DB_SERVERS] && + data[DATABASE_SERVERS_FIELD_NAMES.DATABASE_SERVERS]?.length >= 3) || + (!IS_EXPERT_MODE && data[DATABASE_SERVERS_FIELD_NAMES.DATABASE_SERVERS]?.length >= 3) + ) + return true; + }; + return (data: LocalClustersSummary) => [ { title: t('name'), @@ -165,7 +207,7 @@ const useGetLocalMachineConfig = () => { - {data[CLUSTER_FORM_FIELD_NAMES.IS_HAPROXY_LOAD_BALANCER] + {data[LOAD_BALANCERS_FIELD_NAMES.IS_HAPROXY_ENABLED] ? t('on', { ns: 'shared' }) : t('off', { ns: 'shared' })} @@ -177,16 +219,12 @@ const useGetLocalMachineConfig = () => { children: ( - {data[CLUSTER_FORM_FIELD_NAMES.DATABASE_SERVERS]?.length >= 3 ? ( + {isHighAvailability(data) ? ( ) : ( )} - - {data[CLUSTER_FORM_FIELD_NAMES.DATABASE_SERVERS]?.length >= 3 - ? t('on', { ns: 'shared' }) - : t('off', { ns: 'shared' })} - + {isHighAvailability(data) ? t('on', { ns: 'shared' }) : t('off', { ns: 'shared' })} {t('highAvailabilityInfo')} diff --git a/console/ui/src/widgets/cluster-summary/model/types.ts b/console/ui/src/widgets/cluster-summary/model/types.ts index dfb8a898f1..99cb18a911 100644 --- a/console/ui/src/widgets/cluster-summary/model/types.ts +++ b/console/ui/src/widgets/cluster-summary/model/types.ts @@ -1,5 +1,8 @@ import { ReactElement } from 'react'; import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; +import { LOAD_BALANCERS_FIELD_NAMES } from '@/entities/cluster/load-balancers-block/model/const'; +import { STORAGE_BLOCK_FIELDS } from '@entities/cluster/storage-block/model/const.ts'; +import { DcsBlockFormValues } from '@entities/cluster/expert-mode/dcs-block/model/types.ts'; export interface SharedClusterSummaryProps { [CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME]: string; @@ -21,14 +24,14 @@ export interface CloudProviderClustersSummary extends SharedClusterSummaryProps ram: number; }; [CLUSTER_FORM_FIELD_NAMES.INSTANCES_AMOUNT]: number; - [CLUSTER_FORM_FIELD_NAMES.STORAGE_AMOUNT]: number; + [STORAGE_BLOCK_FIELDS.STORAGE_AMOUNT]: number; [CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]: number; [CLUSTER_FORM_FIELD_NAMES.INSTANCES_AMOUNT]: number; } -export interface LocalClustersSummary extends SharedClusterSummaryProps { +export interface LocalClustersSummary extends SharedClusterSummaryProps, DcsBlockFormValues { [CLUSTER_FORM_FIELD_NAMES.DATABASE_SERVERS]: number; - [CLUSTER_FORM_FIELD_NAMES.IS_HAPROXY_LOAD_BALANCER]: boolean; + [LOAD_BALANCERS_FIELD_NAMES.IS_HAPROXY_ENABLED]: boolean; } export interface UseGetSummaryConfigProps { diff --git a/console/ui/src/widgets/cluster-summary/ui/index.tsx b/console/ui/src/widgets/cluster-summary/ui/index.tsx index 06d83508bd..a121a3c3d0 100644 --- a/console/ui/src/widgets/cluster-summary/ui/index.tsx +++ b/console/ui/src/widgets/cluster-summary/ui/index.tsx @@ -33,7 +33,7 @@ const ClusterSummary: FC = () => { position: 'sticky', top: '100px', margin: '16px', - width: '450px', + width: '370px', height: 'fit-content', padding: '16px 24px 16px 24px', }} diff --git a/console/ui/src/widgets/settings-form/ui/index.tsx b/console/ui/src/widgets/settings-form/ui/index.tsx index f56ea9c769..ceb07a0247 100644 --- a/console/ui/src/widgets/settings-form/ui/index.tsx +++ b/console/ui/src/widgets/settings-form/ui/index.tsx @@ -1,10 +1,10 @@ import { FC, useEffect, useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; -import { SettingsFormValues } from '@entities/settings-proxy-block/model/types.ts'; -import { Box, Stack, Button, CircularProgress } from '@mui/material'; -import SettingsProxyBlock from '@entities/settings-proxy-block'; +import { SettingsFormValues } from '@entities/settings/proxy-block/model/types.ts'; +import { Box, Button, CircularProgress, Stack } from '@mui/material'; +import SettingsProxyBlock from '@entities/settings/proxy-block'; import { useTranslation } from 'react-i18next'; -import { SETTINGS_FORM_FIELDS_NAMES } from '@entities/settings-proxy-block/model/constants.ts'; +import { SETTINGS_FORM_FIELDS_NAMES } from '@entities/settings/proxy-block/model/constants.ts'; import { useGetSettingsQuery, usePatchSettingsByNameMutation, @@ -13,6 +13,8 @@ import { import { toast } from 'react-toastify'; import { handleRequestErrorCatch } from '@shared/lib/functions.ts'; import Spinner from '@shared/ui/spinner'; +import SettingExpertModeBlock from '@entities/settings/expert-mode-block/ui'; +import { LOCAL_STORAGE_ITEMS } from '@shared/model/constants.ts'; const SettingsForm: FC = () => { const { t } = useTranslation(['shared', 'toasts']); @@ -24,6 +26,10 @@ const SettingsForm: FC = () => { defaultValues: { [SETTINGS_FORM_FIELDS_NAMES.HTTP_PROXY]: '', [SETTINGS_FORM_FIELDS_NAMES.HTTPS_PROXY]: '', + [SETTINGS_FORM_FIELDS_NAMES.IS_EXPERT_MODE_ENABLED]: + localStorage.getItem(LOCAL_STORAGE_ITEMS.IS_EXPERT_MODE)?.toString() === 'true', + [SETTINGS_FORM_FIELDS_NAMES.IS_YAML_ENABLED]: + localStorage.getItem(LOCAL_STORAGE_ITEMS.IS_YAML_ENABLED)?.toString() === 'true', }, }); @@ -34,12 +40,12 @@ const SettingsForm: FC = () => { const { isValid, isDirty } = methods.formState; useEffect(() => { - if (settings.isFetching) setIsResetting(true); if (settings.data?.data) { + setIsResetting(true); // eslint-disable-next-line @typescript-eslint/require-await const resetForm = async () => { // sync function will result in form values setting error - const settingsData = settings.data.data?.find((value) => value.name === 'proxy_env')?.value; + const settingsData = settings.data?.data?.find((value) => value.name === 'proxy_env')?.value; methods.reset((values) => ({ ...values, ...settingsData, @@ -51,19 +57,44 @@ const SettingsForm: FC = () => { const onSubmit = async (values: SettingsFormValues) => { try { - const filledFormValues = Object.fromEntries(Object.entries(values).filter(([_, value]) => value !== '')); - settings.data?.data?.find((value) => value?.name === 'proxy_env')?.value && isDirty - ? await patchSettingsTrigger({ + const dirtyFields = methods.formState.dirtyFields; + if ( + dirtyFields[SETTINGS_FORM_FIELDS_NAMES.IS_EXPERT_MODE_ENABLED] || + dirtyFields[SETTINGS_FORM_FIELDS_NAMES.IS_YAML_ENABLED] + ) { + localStorage.setItem( + LOCAL_STORAGE_ITEMS.IS_EXPERT_MODE, + String(values[SETTINGS_FORM_FIELDS_NAMES.IS_EXPERT_MODE_ENABLED]), + ); + localStorage.setItem( + LOCAL_STORAGE_ITEMS.IS_YAML_ENABLED, + String(values[SETTINGS_FORM_FIELDS_NAMES.IS_YAML_ENABLED]), + ); + dispatchEvent(new Event('storage')); + } + if ( + dirtyFields?.[SETTINGS_FORM_FIELDS_NAMES.HTTP_PROXY] || + dirtyFields?.[SETTINGS_FORM_FIELDS_NAMES.HTTPS_PROXY] + ) { + const filledFormValues = Object.fromEntries( + Object.entries(values).filter( + ([key, value]) => value !== '' && key !== SETTINGS_FORM_FIELDS_NAMES.IS_EXPERT_MODE_ENABLED, + ), + ); + if (settings.data?.data?.find((value) => value?.name === 'proxy_env')?.value && isDirty) + await patchSettingsTrigger({ name: 'proxy_env', requestChangeSetting: { value: { ...filledFormValues } }, - }).unwrap() - : await postSettingsTrigger({ + }).unwrap(); + else + await postSettingsTrigger({ requestCreateSetting: { name: 'proxy_env', value: { ...filledFormValues }, }, }).unwrap(); - toast.success(t('settingsSuccessfullyChanged', { ns: 'toasts' })); + toast.success(t('settingsSuccessfullyChanged', { ns: 'toasts' })); + } methods.reset(values); } catch (e) { handleRequestErrorCatch(e); @@ -79,12 +110,18 @@ const SettingsForm: FC = () => {
+ diff --git a/console/ui/src/widgets/sidebar/model/constants.ts b/console/ui/src/widgets/sidebar/model/constants.ts index fb263faf2f..39e39a69ee 100644 --- a/console/ui/src/widgets/sidebar/model/constants.ts +++ b/console/ui/src/widgets/sidebar/model/constants.ts @@ -49,6 +49,6 @@ export const sidebarLowData = (t: TFunction) => [ }, ]; -export const OPEN_SIDEBAR_WIDTH = '220px'; +export const OPEN_SIDEBAR_WIDTH = '222px'; -export const COLLAPSED_SIDEBAR_WIDTH = '62px'; +export const COLLAPSED_SIDEBAR_WIDTH = '66px'; diff --git a/console/ui/src/widgets/sidebar/ui/index.tsx b/console/ui/src/widgets/sidebar/ui/index.tsx index 177b29b8dd..b4b56969d5 100644 --- a/console/ui/src/widgets/sidebar/ui/index.tsx +++ b/console/ui/src/widgets/sidebar/ui/index.tsx @@ -17,7 +17,7 @@ const Sidebar = () => { const toggleSidebarCollapse = () => { setIsCollapsed((prev) => { const newValue = !prev; - localStorage.setItem('isSidebarCollapsed', newValue); + localStorage.setItem('isSidebarCollapsed', String(newValue)); return newValue; }); }; @@ -30,25 +30,13 @@ const Sidebar = () => { if ((!isCollapsed && isLesserThan1600) || (isCollapsed && !isLesserThan1600)) toggleSidebarCollapse(); }, [isLesserThan1600]); - const sidebarItems = sidebarData(t).map((item) => ( - - )); - - const sidebarLowIcons = sidebarLowData(t).map((item) => ( - - )); - return ( { }, }}> - - {sidebarItems} + + + {sidebarData(t).map((item) => ( + + ))} + - {sidebarLowIcons} + + {sidebarLowData(t).map((item) => ( + + ))} + ({ + ...getBaseClusterExtraVars(values), + ...(values[CLUSTER_FORM_FIELD_NAMES.PROVIDER]?.code !== PROVIDERS.LOCAL + ? { ...getCloudProviderExtraVars(values) } + : { ...getLocalMachineExtraVars(values) }), +}); diff --git a/console/ui/src/widgets/yaml-editor-form/modal/const.ts b/console/ui/src/widgets/yaml-editor-form/modal/const.ts new file mode 100644 index 0000000000..45b4c6c9f5 --- /dev/null +++ b/console/ui/src/widgets/yaml-editor-form/modal/const.ts @@ -0,0 +1,7 @@ +export const YAML_EDITOR_FORM_FIELD_NAMES = Object.freeze({ + EDITOR: 'editor', +}); + +export const YAML_EDITOR_FORM_DEFAULT_VALUES = Object.freeze({ + [YAML_EDITOR_FORM_FIELD_NAMES.EDITOR]: '', +}); diff --git a/console/ui/src/widgets/yaml-editor-form/modal/types.ts b/console/ui/src/widgets/yaml-editor-form/modal/types.ts new file mode 100644 index 0000000000..6351bd99a3 --- /dev/null +++ b/console/ui/src/widgets/yaml-editor-form/modal/types.ts @@ -0,0 +1,5 @@ +import { YAML_EDITOR_FORM_FIELD_NAMES } from '@widgets/yaml-editor-form/modal/const.ts'; + +export interface YamlEditorFormValues { + [YAML_EDITOR_FORM_FIELD_NAMES.EDITOR]: string; +} diff --git a/console/ui/src/widgets/yaml-editor-form/ui/index.tsx b/console/ui/src/widgets/yaml-editor-form/ui/index.tsx new file mode 100644 index 0000000000..362a490040 --- /dev/null +++ b/console/ui/src/widgets/yaml-editor-form/ui/index.tsx @@ -0,0 +1,106 @@ +import { FC, useEffect, useRef } from 'react'; +import { Editor } from '@monaco-editor/react'; +import { editor } from 'monaco-editor'; +import DefaultFormButtons from '@shared/ui/default-form-buttons'; +import { useTranslation } from 'react-i18next'; +import { generateAbsoluteRouterPath, handleRequestErrorCatch } from '@shared/lib/functions.ts'; +import RouterPaths from '@app/router/routerPathsConfig'; +import { useNavigate } from 'react-router-dom'; +import { Controller, useForm, useWatch } from 'react-hook-form'; +import { + YAML_EDITOR_FORM_DEFAULT_VALUES, + YAML_EDITOR_FORM_FIELD_NAMES, +} from '@widgets/yaml-editor-form/modal/const.ts'; +import { YamlEditorFormValues } from '@widgets/yaml-editor-form/modal/types.ts'; +import { RequestClusterCreate, usePostClustersMutation } from '@/shared/api/api/clusters'; +import { Box, Stack, useTheme } from '@mui/material'; +import { toast } from 'react-toastify'; +import * as YAML from 'yaml'; +import ErrorBox from '@shared/ui/error-box/ui'; +import { ErrorBoundary } from 'react-error-boundary'; +import { mapFormValuesToYamlEditor } from '@widgets/yaml-editor-form/lib/functions.ts'; +import { mapFormValuesToRequestFields } from '@shared/lib/clusterValuesTransformFunctions.ts'; +import { useAppSelector } from '@app/redux/store/hooks.ts'; +import { selectCurrentProject } from '@app/redux/slices/projectSlice/projectSelectors.ts'; +import IStandaloneCodeEditor = editor.IStandaloneCodeEditor; + +const YamlEditorForm: FC = () => { + const theme = useTheme(); + const { t } = useTranslation('clusters'); + const editorRef = useRef(null); + const navigate = useNavigate(); + const currentProject = useAppSelector(selectCurrentProject); + + const watchUiValues = useWatch(); + + const [postClusterTrigger, postClusterTriggerState] = usePostClustersMutation(); + + const { control, handleSubmit, formState, setValue } = useForm({ + mode: 'all', + defaultValues: YAML_EDITOR_FORM_DEFAULT_VALUES, + }); + + function handleEditorDidMount(editor: IStandaloneCodeEditor) { + editorRef.current = editor; + editorRef.current.focus(); + } + + const onSubmit = async (values: YamlEditorFormValues) => { + try { + await postClusterTrigger({ + requestClusterCreate: mapFormValuesToRequestFields({ + values: watchUiValues as RequestClusterCreate, + projectId: Number(currentProject), + customExtraVars: YAML.parse(values[YAML_EDITOR_FORM_FIELD_NAMES.EDITOR]), + }), + }).unwrap(); + toast.info(t('clusterSuccessfullyCreated', { ns: 'toasts' })); + await navigate(generateAbsoluteRouterPath(RouterPaths.clusters.absolutePath)); + } catch (e) { + handleRequestErrorCatch(e); + } + }; + + const cancelHandler = () => navigate(generateAbsoluteRouterPath(RouterPaths.clusters.absolutePath)); + + const { isValid, isSubmitting } = formState; + + useEffect(() => { + setValue( + YAML_EDITOR_FORM_FIELD_NAMES.EDITOR, + YAML.stringify(mapFormValuesToYamlEditor(watchUiValues), { sortMapEntries: true }), + ); + }, []); + + return ( + }> + + + + ( + + )} + /> + + + + + + ); +}; + +export default YamlEditorForm; diff --git a/console/ui/vite.config.mts b/console/ui/vite.config.mts index c1c2bebd5d..1fb2a2eefc 100644 --- a/console/ui/vite.config.mts +++ b/console/ui/vite.config.mts @@ -1,8 +1,8 @@ -import {defineConfig} from 'vite'; +import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react-swc'; import svgr from 'vite-plugin-svgr'; -import {resolve} from 'path'; -import fixReactVirtualized from 'esbuild-plugin-react-virtualized' +import { resolve } from 'path'; +import fixReactVirtualized from 'esbuild-plugin-react-virtualized'; // https://vitejs.dev/config/ export default defineConfig({ @@ -13,6 +13,9 @@ export default defineConfig({ plugins: [fixReactVirtualized], }, }, + preview: { + port: 8080, + }, resolve: { alias: { '@': resolve(__dirname, './src'), diff --git a/console/ui/yarn.lock b/console/ui/yarn.lock index 9aeb1d8712..218c3e1e44 100644 --- a/console/ui/yarn.lock +++ b/console/ui/yarn.lock @@ -84,28 +84,7 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.28.5.tgz#a8a4962e1567121ac0b3b487f52107443b455c7f" integrity sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA== -"@babel/core@^7.21.3": - version "7.28.4" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.28.4.tgz#12a550b8794452df4c8b084f95003bce1742d496" - integrity sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA== - dependencies: - "@babel/code-frame" "^7.27.1" - "@babel/generator" "^7.28.3" - "@babel/helper-compilation-targets" "^7.27.2" - "@babel/helper-module-transforms" "^7.28.3" - "@babel/helpers" "^7.28.4" - "@babel/parser" "^7.28.4" - "@babel/template" "^7.27.2" - "@babel/traverse" "^7.28.4" - "@babel/types" "^7.28.4" - "@jridgewell/remapping" "^2.3.5" - convert-source-map "^2.0.0" - debug "^4.1.0" - gensync "^1.0.0-beta.2" - json5 "^2.2.3" - semver "^6.3.1" - -"@babel/core@^7.28.4": +"@babel/core@^7.21.3", "@babel/core@^7.28.4": version "7.28.5" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.28.5.tgz#4c81b35e51e1b734f510c99b07dfbc7bbbb48f7e" integrity sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw== @@ -126,7 +105,7 @@ json5 "^2.2.3" semver "^6.3.1" -"@babel/generator@^7.28.3", "@babel/generator@^7.28.5": +"@babel/generator@^7.28.5": version "7.28.5" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.5.tgz#712722d5e50f44d07bc7ac9fe84438742dd61298" integrity sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ== @@ -198,7 +177,7 @@ "@babel/template" "^7.27.2" "@babel/types" "^7.28.4" -"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.27.2", "@babel/parser@^7.28.4", "@babel/parser@^7.28.5": +"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.27.2", "@babel/parser@^7.28.5": version "7.28.5" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.5.tgz#0b0225ee90362f030efd644e8034c99468893b08" integrity sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ== @@ -219,12 +198,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" -"@babel/runtime@^7.12.5", "@babel/runtime@^7.18.3", "@babel/runtime@^7.23.2", "@babel/runtime@^7.7.2": - version "7.28.3" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.3.tgz#75c5034b55ba868121668be5d5bb31cc64e6e61a" - integrity sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA== - -"@babel/runtime@^7.27.6", "@babel/runtime@^7.28.3", "@babel/runtime@^7.28.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7": +"@babel/runtime@^7.12.5", "@babel/runtime@^7.18.3", "@babel/runtime@^7.23.2", "@babel/runtime@^7.27.6", "@babel/runtime@^7.28.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.7": version "7.28.4" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326" integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ== @@ -238,7 +212,7 @@ "@babel/parser" "^7.27.2" "@babel/types" "^7.27.1" -"@babel/traverse@^7.27.1", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.4", "@babel/traverse@^7.28.5": +"@babel/traverse@^7.27.1", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.5": version "7.28.5" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.5.tgz#450cab9135d21a7a2ca9d2d35aa05c20e68c360b" integrity sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ== @@ -251,7 +225,7 @@ "@babel/types" "^7.28.5" debug "^4.3.1" -"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.27.1", "@babel/types@^7.28.2", "@babel/types@^7.28.4", "@babel/types@^7.28.5": +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.21.3", "@babel/types@^7.27.1", "@babel/types@^7.28.2", "@babel/types@^7.28.4", "@babel/types@^7.28.5": version "7.28.5" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.5.tgz#10fc405f60897c35f07e85493c932c7b5ca0592b" integrity sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA== @@ -259,14 +233,6 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" -"@babel/types@^7.21.3": - version "7.28.4" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.4.tgz#0a4e618f4c60a7cd6c11cb2d48060e4dbe38ac3a" - integrity sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q== - dependencies: - "@babel/helper-string-parser" "^7.27.1" - "@babel/helper-validator-identifier" "^7.27.1" - "@csstools/color-helpers@^5.1.0": version "5.1.0" resolved "https://registry.yarnpkg.com/@csstools/color-helpers/-/color-helpers-5.1.0.tgz#106c54c808cabfd1ab4c602d8505ee584c2996ef" @@ -329,9 +295,9 @@ integrity sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g== "@emotion/is-prop-valid@^1.3.0": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz#8d5cf1132f836d7adbe42cf0b49df7816fc88240" - integrity sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw== + version "1.4.0" + resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz#e9ad47adff0b5c94c72db3669ce46de33edf28c0" + integrity sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw== dependencies: "@emotion/memoize" "^0.9.0" @@ -402,261 +368,131 @@ resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz#5e13fac887f08c44f76b0ccaf3370eb00fec9bb6" integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== -"@esbuild/aix-ppc64@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz#2ae33300598132cc4cf580dbbb28d30fed3c5c49" - integrity sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg== - "@esbuild/aix-ppc64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz#80fcbe36130e58b7670511e888b8e88a259ed76c" integrity sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA== -"@esbuild/android-arm64@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz#927708b3db5d739d6cb7709136924cc81bec9b03" - integrity sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ== - "@esbuild/android-arm64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz#8aa4965f8d0a7982dc21734bf6601323a66da752" integrity sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg== -"@esbuild/android-arm@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.11.tgz#571f94e7f4068957ec4c2cfb907deae3d01b55ae" - integrity sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg== - "@esbuild/android-arm@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz#300712101f7f50f1d2627a162e6e09b109b6767a" integrity sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg== -"@esbuild/android-x64@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.11.tgz#8a3bf5cae6c560c7ececa3150b2bde76e0fb81e6" - integrity sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g== - "@esbuild/android-x64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz#87dfb27161202bdc958ef48bb61b09c758faee16" integrity sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg== -"@esbuild/darwin-arm64@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz#0a678c4ac4bf8717e67481e1a797e6c152f93c84" - integrity sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w== - "@esbuild/darwin-arm64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz#79197898ec1ff745d21c071e1c7cc3c802f0c1fd" integrity sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg== -"@esbuild/darwin-x64@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz#70f5e925a30c8309f1294d407a5e5e002e0315fe" - integrity sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ== - "@esbuild/darwin-x64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz#146400a8562133f45c4d2eadcf37ddd09718079e" integrity sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA== -"@esbuild/freebsd-arm64@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz#4ec1db687c5b2b78b44148025da9632397553e8a" - integrity sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA== - "@esbuild/freebsd-arm64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz#1c5f9ba7206e158fd2b24c59fa2d2c8bb47ca0fe" integrity sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg== -"@esbuild/freebsd-x64@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz#4c81abd1b142f1e9acfef8c5153d438ca53f44bb" - integrity sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw== - "@esbuild/freebsd-x64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz#ea631f4a36beaac4b9279fa0fcc6ca29eaeeb2b3" integrity sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ== -"@esbuild/linux-arm64@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz#69517a111acfc2b93aa0fb5eaeb834c0202ccda5" - integrity sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA== - "@esbuild/linux-arm64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz#e1066bce58394f1b1141deec8557a5f0a22f5977" integrity sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ== -"@esbuild/linux-arm@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz#58dac26eae2dba0fac5405052b9002dac088d38f" - integrity sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw== - "@esbuild/linux-arm@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz#452cd66b20932d08bdc53a8b61c0e30baf4348b9" integrity sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw== -"@esbuild/linux-ia32@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz#b89d4efe9bdad46ba944f0f3b8ddd40834268c2b" - integrity sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw== - "@esbuild/linux-ia32@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz#b24f8acc45bcf54192c7f2f3be1b53e6551eafe0" integrity sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA== -"@esbuild/linux-loong64@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz#11f603cb60ad14392c3f5c94d64b3cc8b630fbeb" - integrity sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw== - "@esbuild/linux-loong64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz#f9cfffa7fc8322571fbc4c8b3268caf15bd81ad0" integrity sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng== -"@esbuild/linux-mips64el@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz#b7d447ff0676b8ab247d69dac40a5cf08e5eeaf5" - integrity sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ== - "@esbuild/linux-mips64el@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz#575a14bd74644ffab891adc7d7e60d275296f2cd" integrity sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw== -"@esbuild/linux-ppc64@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz#b3a28ed7cc252a61b07ff7c8fd8a984ffd3a2f74" - integrity sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw== - "@esbuild/linux-ppc64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz#75b99c70a95fbd5f7739d7692befe60601591869" integrity sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA== -"@esbuild/linux-riscv64@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz#ce75b08f7d871a75edcf4d2125f50b21dc9dc273" - integrity sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww== - "@esbuild/linux-riscv64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz#2e3259440321a44e79ddf7535c325057da875cd6" integrity sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w== -"@esbuild/linux-s390x@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz#cd08f6c73b6b6ff9ccdaabbd3ff6ad3dca99c263" - integrity sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw== - "@esbuild/linux-s390x@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz#17676cabbfe5928da5b2a0d6df5d58cd08db2663" integrity sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg== -"@esbuild/linux-x64@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz#3c3718af31a95d8946ebd3c32bb1e699bdf74910" - integrity sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ== - "@esbuild/linux-x64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz#0583775685ca82066d04c3507f09524d3cd7a306" integrity sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw== -"@esbuild/netbsd-arm64@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz#b4c767082401e3a4e8595fe53c47cd7f097c8077" - integrity sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg== - "@esbuild/netbsd-arm64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz#f04c4049cb2e252fe96b16fed90f70746b13f4a4" integrity sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg== -"@esbuild/netbsd-x64@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz#f2a930458ed2941d1f11ebc34b9c7d61f7a4d034" - integrity sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A== - "@esbuild/netbsd-x64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz#77da0d0a0d826d7c921eea3d40292548b258a076" integrity sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ== -"@esbuild/openbsd-arm64@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz#b4ae93c75aec48bc1e8a0154957a05f0641f2dad" - integrity sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg== - "@esbuild/openbsd-arm64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz#6296f5867aedef28a81b22ab2009c786a952dccd" integrity sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A== -"@esbuild/openbsd-x64@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz#b42863959c8dcf9b01581522e40012d2c70045e2" - integrity sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw== - "@esbuild/openbsd-x64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz#f8d23303360e27b16cf065b23bbff43c14142679" integrity sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw== -"@esbuild/openharmony-arm64@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz#b2e717141c8fdf6bddd4010f0912e6b39e1640f1" - integrity sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ== - "@esbuild/openharmony-arm64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz#49e0b768744a3924be0d7fd97dd6ce9b2923d88d" integrity sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg== -"@esbuild/sunos-x64@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz#9fbea1febe8778927804828883ec0f6dd80eb244" - integrity sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA== - "@esbuild/sunos-x64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz#a6ed7d6778d67e528c81fb165b23f4911b9b13d6" integrity sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w== -"@esbuild/win32-arm64@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz#501539cedb24468336073383989a7323005a8935" - integrity sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q== - "@esbuild/win32-arm64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz#9ac14c378e1b653af17d08e7d3ce34caef587323" integrity sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg== -"@esbuild/win32-ia32@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz#8ac7229aa82cef8f16ffb58f1176a973a7a15343" - integrity sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA== - "@esbuild/win32-ia32@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz#918942dcbbb35cc14fca39afb91b5e6a3d127267" integrity sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ== -"@esbuild/win32-x64@0.25.11": - version "0.25.11" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz#5ecda6f3fe138b7e456f4e429edde33c823f392f" - integrity sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA== - "@esbuild/win32-x64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz#9bdad8176be7811ad148d1f8772359041f46c6c5" @@ -669,12 +505,7 @@ dependencies: eslint-visitor-keys "^3.4.3" -"@eslint-community/regexpp@^4.10.0": - version "4.12.1" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" - integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== - -"@eslint-community/regexpp@^4.12.1": +"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.12.1": version "4.12.2" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== @@ -745,12 +576,12 @@ resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-9.9.0.tgz#3ad015fbbaaae7af3149555e0f22b4b30134c69d" integrity sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA== -"@fontsource/roboto@^5.2.6": +"@fontsource/roboto@^5.2.8": version "5.2.8" resolved "https://registry.yarnpkg.com/@fontsource/roboto/-/roboto-5.2.8.tgz#73411c434b9162019b8ca18abd8245489809cca9" integrity sha512-oh9g4Cg3loVMz9MWeKWfDI+ooxxG1aRVetkiKIb2ESS2rrryGecQ/y4pAj4z5A5ebyw450dYRi/c4k/I3UBhHA== -"@hookform/resolvers@^5.2.1": +"@hookform/resolvers@^5.2.2": version "5.2.2" resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-5.2.2.tgz#5ac16cd89501ca31671e6e9f0f5c5d762a99aa12" integrity sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA== @@ -827,9 +658,9 @@ "@types/whatwg-streams" "^0.0.7" "@monaco-editor/loader@^1.5.0": - version "1.5.0" - resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.5.0.tgz#dcdbc7fe7e905690fb449bed1c251769f325c55d" - integrity sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw== + version "1.6.1" + resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.6.1.tgz#c99177d87765abf10de31a0086084e714acfbc0f" + integrity sha512-w3tEnj9HYEC73wtjdpR089AqkUPskFRcdkxsiSFt3SoUc3OHpmu+leP94CXBm4mHfefmhsdfI0ZQu6qJ0wgtPg== dependencies: state-local "^1.0.6" @@ -840,61 +671,61 @@ dependencies: "@monaco-editor/loader" "^1.5.0" -"@mui/core-downloads-tracker@^7.3.4": - version "7.3.4" - resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.4.tgz#8da7f546ed98102a33faa61a7b765f14c466103b" - integrity sha512-BIktMapG3r4iXwIhYNpvk97ZfYWTreBBQTWjQKbNbzI64+ULHfYavQEX2w99aSWHS58DvXESWIgbD9adKcUOBw== +"@mui/core-downloads-tracker@^7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.5.tgz#2c7769498a287eb9456269571b328f807b69f731" + integrity sha512-kOLwlcDPnVz2QMhiBv0OQ8le8hTCqKM9cRXlfVPL91l3RGeOsxrIhNRsUt3Xb8wb+pTVUolW+JXKym93vRKxCw== -"@mui/icons-material@^7.3.1": - version "7.3.4" - resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-7.3.4.tgz#a273879765c369a1865a2eeb1240d71db3e6da19" - integrity sha512-9n6Xcq7molXWYb680N2Qx+FRW8oT6j/LXF5PZFH3ph9X/Rct0B/BlLAsFI7iL9ySI6LVLuQIVtrLiPT82R7OZw== +"@mui/icons-material@^7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-7.3.5.tgz#ebb94784fc49ab477f97d4cae097ec3cee32758f" + integrity sha512-LciL1GLMZ+VlzyHAALSVAR22t8IST4LCXmljcUSx2NOutgO2XnxdIp8ilFbeNf9wpo0iUFbAuoQcB7h+HHIf3A== dependencies: "@babel/runtime" "^7.28.4" -"@mui/lab@^7.0.0-beta.16": - version "7.0.0-beta.17" - resolved "https://registry.yarnpkg.com/@mui/lab/-/lab-7.0.0-beta.17.tgz#0629b4388d16520ed95712bc2995679c2af849be" - integrity sha512-H8tSINm6Xgbi7o49MplAwks4tAEE6SpFNd9l7n4NURl0GSpOv0CZvgXKSJt4+6TmquDhE7pomHpHWJiVh/2aCg== +"@mui/lab@^7.0.1-beta.19": + version "7.0.1-beta.19" + resolved "https://registry.yarnpkg.com/@mui/lab/-/lab-7.0.1-beta.19.tgz#c429589b9a895004e4f919f322322ae78ce455c3" + integrity sha512-Ekxd2mPnr5iKwrMXjN/y2xgpxPX8ithBBcDenjqNdBt/ZQumrmBl0ifVoqAHsL6lxN6DOgRsWTRc4eOdDiB+0Q== dependencies: - "@babel/runtime" "^7.28.3" - "@mui/system" "^7.3.2" - "@mui/types" "^7.4.6" - "@mui/utils" "^7.3.2" + "@babel/runtime" "^7.28.4" + "@mui/system" "^7.3.5" + "@mui/types" "^7.4.8" + "@mui/utils" "^7.3.5" clsx "^2.1.1" prop-types "^15.8.1" -"@mui/material@^7.3.1": - version "7.3.4" - resolved "https://registry.yarnpkg.com/@mui/material/-/material-7.3.4.tgz#945d69bafd615aaadd29b3a06b91b02d51c26e09" - integrity sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw== +"@mui/material@^7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@mui/material/-/material-7.3.5.tgz#2a30e9ed33c58cfa90d8a5d74c12cfa1064f52ef" + integrity sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ== dependencies: "@babel/runtime" "^7.28.4" - "@mui/core-downloads-tracker" "^7.3.4" - "@mui/system" "^7.3.3" - "@mui/types" "^7.4.7" - "@mui/utils" "^7.3.3" + "@mui/core-downloads-tracker" "^7.3.5" + "@mui/system" "^7.3.5" + "@mui/types" "^7.4.8" + "@mui/utils" "^7.3.5" "@popperjs/core" "^2.11.8" "@types/react-transition-group" "^4.4.12" clsx "^2.1.1" csstype "^3.1.3" prop-types "^15.8.1" - react-is "^19.1.1" + react-is "^19.2.0" react-transition-group "^4.4.5" -"@mui/private-theming@^7.3.3": - version "7.3.3" - resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-7.3.3.tgz#f53817c76966b1cbede367fcfea631ac18ad7e84" - integrity sha512-OJM+9nj5JIyPUvsZ5ZjaeC9PfktmK+W5YaVLToLR8L0lB/DGmv1gcKE43ssNLSvpoW71Hct0necfade6+kW3zQ== +"@mui/private-theming@^7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-7.3.5.tgz#53f9203d7d82e69e94dd8df0a19fd4744a330a8f" + integrity sha512-cTx584W2qrLonwhZLbEN7P5pAUu0nZblg8cLBlTrZQ4sIiw8Fbvg7GvuphQaSHxPxrCpa7FDwJKtXdbl2TSmrA== dependencies: "@babel/runtime" "^7.28.4" - "@mui/utils" "^7.3.3" + "@mui/utils" "^7.3.5" prop-types "^15.8.1" -"@mui/styled-engine@^7.3.3": - version "7.3.3" - resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-7.3.3.tgz#c943d6c3abcdb3be569625bbbd7c47444f87f8c8" - integrity sha512-CmFxvRJIBCEaWdilhXMw/5wFJ1+FT9f3xt+m2pPXhHPeVIbBg9MnMvNSJjdALvnQJMPw8jLhrUtXmN7QAZV2fw== +"@mui/styled-engine@^7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-7.3.5.tgz#b087d791d85eea97812f0e23e9b9fdeb37abad77" + integrity sha512-zbsZ0uYYPndFCCPp2+V3RLcAN6+fv4C8pdwRx6OS3BwDkRCN8WBehqks7hWyF3vj1kdQLIWrpdv/5Y0jHRxYXQ== dependencies: "@babel/runtime" "^7.28.4" "@emotion/cache" "^11.14.0" @@ -903,40 +734,40 @@ csstype "^3.1.3" prop-types "^15.8.1" -"@mui/system@^7", "@mui/system@^7.3.2", "@mui/system@^7.3.3": - version "7.3.3" - resolved "https://registry.yarnpkg.com/@mui/system/-/system-7.3.3.tgz#9184a0cd2b2da9425755d7b5acf00796cc14d595" - integrity sha512-Lqq3emZr5IzRLKaHPuMaLBDVaGvxoh6z7HMWd1RPKawBM5uMRaQ4ImsmmgXWtwJdfZux5eugfDhXJUo2mliS8Q== +"@mui/system@^7", "@mui/system@^7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@mui/system/-/system-7.3.5.tgz#ea077787ba9e9efc00a6df4db55a833de6a530fc" + integrity sha512-yPaf5+gY3v80HNkJcPi6WT+r9ebeM4eJzrREXPxMt7pNTV/1eahyODO4fbH3Qvd8irNxDFYn5RQ3idHW55rA6g== dependencies: "@babel/runtime" "^7.28.4" - "@mui/private-theming" "^7.3.3" - "@mui/styled-engine" "^7.3.3" - "@mui/types" "^7.4.7" - "@mui/utils" "^7.3.3" + "@mui/private-theming" "^7.3.5" + "@mui/styled-engine" "^7.3.5" + "@mui/types" "^7.4.8" + "@mui/utils" "^7.3.5" clsx "^2.1.1" csstype "^3.1.3" prop-types "^15.8.1" -"@mui/types@^7.4.6", "@mui/types@^7.4.7": - version "7.4.7" - resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.4.7.tgz#7231f6c050586b5e732c7169f4a934e1856efd51" - integrity sha512-8vVje9rdEr1rY8oIkYgP+Su5Kwl6ik7O3jQ0wl78JGSmiZhRHV+vkjooGdKD8pbtZbutXFVTWQYshu2b3sG9zw== +"@mui/types@^7.4.8": + version "7.4.8" + resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.4.8.tgz#0c1829353cd7d196be9ac0332a30cdd2792f3558" + integrity sha512-ZNXLBjkPV6ftLCmmRCafak3XmSn8YV0tKE/ZOhzKys7TZXUiE0mZxlH8zKDo6j6TTUaDnuij68gIG+0Ucm7Xhw== dependencies: "@babel/runtime" "^7.28.4" -"@mui/utils@^7.3.2", "@mui/utils@^7.3.3": - version "7.3.3" - resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-7.3.3.tgz#9fe030b94451466fba51428804937286c5103a95" - integrity sha512-kwNAUh7bLZ7mRz9JZ+6qfRnnxbE4Zuc+RzXnhSpRSxjTlSTj7b4JxRLXpG+MVtPVtqks5k/XC8No1Vs3x4Z2gg== +"@mui/utils@^7.3.3", "@mui/utils@^7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-7.3.5.tgz#77f3e2b83454bbd47877c73b04cd804f490bf028" + integrity sha512-jisvFsEC3sgjUjcPnR4mYfhzjCDIudttSGSbe1o/IXFNu0kZuR+7vqQI0jg8qtcVZBHWrwTfvAZj9MNMumcq1g== dependencies: "@babel/runtime" "^7.28.4" - "@mui/types" "^7.4.7" + "@mui/types" "^7.4.8" "@types/prop-types" "^15.7.15" clsx "^2.1.1" prop-types "^15.8.1" - react-is "^19.1.1" + react-is "^19.2.0" -"@mui/x-data-grid@^8.10.2": +"@mui/x-data-grid@^8.16.0": version "8.16.0" resolved "https://registry.yarnpkg.com/@mui/x-data-grid/-/x-data-grid-8.16.0.tgz#c054eeb50f6702e310e825b5ad2edc0536e3fd42" integrity sha512-yJ+v+E1yI1HxrEUdOfgrUTCxobAFvotGggU6cy6MnM7c7/TPPg9d5mDzjzxb0imOCJ6WyiM/vtd5WKbY/5sUNw== @@ -949,7 +780,7 @@ prop-types "^15.8.1" use-sync-external-store "^1.6.0" -"@mui/x-date-pickers@^8.10.2": +"@mui/x-date-pickers@^8.16.0": version "8.16.0" resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-8.16.0.tgz#34c2384451956de99ed7b49536043d63f914ecce" integrity sha512-zvUoO9ImWiKRaOWvQVbB1vCa6aUQIX5GM0tJ+nAyNNIVV0VqpXz3CvkRR6ovBBFzIcChc7FXlqrMKcJ//EhePQ== @@ -1101,10 +932,10 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== -"@reduxjs/toolkit@^2.8.2": - version "2.10.0" - resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.10.0.tgz#de8f805a2e03a648eff5115206d2a19568ac2002" - integrity sha512-DCDKKB+DDy00kULFGoj5jtxRXksCGtEKEPbeSqRi20/vUrwrBD+zyJx+jQ7xkIU1pUHQ6OIYHyc9NiVSEjAlNA== +"@reduxjs/toolkit@^2.10.1": + version "2.10.1" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.10.1.tgz#b3b9da21335eea409d3498544bcaf83b481a0195" + integrity sha512-/U17EXQ9Do9Yx4DlNGU6eVNfZvFJfYpUtRRdLf19PbPjdWBxNlxGZXywQZ1p1Nz8nMkWplTI7iD/23m07nolDA== dependencies: "@standard-schema/spec" "^1.0.0" "@standard-schema/utils" "^0.3.0" @@ -1237,7 +1068,7 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz#1657f56326bbe0ac80eedc9f9c18fc1ddd24e107" integrity sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg== -"@rtk-query/codegen-openapi@^2.0.0": +"@rtk-query/codegen-openapi@^2.1.0": version "2.1.0" resolved "https://registry.yarnpkg.com/@rtk-query/codegen-openapi/-/codegen-openapi-2.1.0.tgz#d0abaad8198650407b7c61fc4e5c47ce7196a2f7" integrity sha512-JQ2L7fIVEo43ccVOv9Wm4kjyb4QfDA3ndTjAU+oYh0fnY4lnCQQiNDxiZlo97GbjX2lVdmlaBuWtk/gwK+50OA== @@ -1344,74 +1175,74 @@ "@svgr/hast-util-to-babel-ast" "8.0.0" svg-parser "^2.0.4" -"@swc/core-darwin-arm64@1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.14.0.tgz#1db614b52ed7369f47be2a1c6b5e80b6be923898" - integrity sha512-uHPC8rlCt04nvYNczWzKVdgnRhxCa3ndKTBBbBpResOZsRmiwRAvByIGh599j+Oo6Z5eyTPrgY+XfJzVmXnN7Q== - -"@swc/core-darwin-x64@1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.14.0.tgz#900e56924994d0e723e6088e2a2e1a1c08c59a95" - integrity sha512-2SHrlpl68vtePRknv9shvM9YKKg7B9T13tcTg9aFCwR318QTYo+FzsKGmQSv9ox/Ua0Q2/5y2BNjieffJoo4nA== - -"@swc/core-linux-arm-gnueabihf@1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.14.0.tgz#3c84966a8c6e308b0788d1c7875bce23c65134c6" - integrity sha512-SMH8zn01dxt809svetnxpeg/jWdpi6dqHKO3Eb11u4OzU2PK7I5uKS6gf2hx5LlTbcJMFKULZiVwjlQLe8eqtg== - -"@swc/core-linux-arm64-gnu@1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.14.0.tgz#5190097d2ca4ea8b198f46a3abe2272331575b54" - integrity sha512-q2JRu2D8LVqGeHkmpVCljVNltG0tB4o4eYg+dElFwCS8l2Mnt9qurMCxIeo9mgoqz0ax+k7jWtIRHktnVCbjvQ== - -"@swc/core-linux-arm64-musl@1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.14.0.tgz#420f510102a37feda0e3dfb8d21651515251476b" - integrity sha512-uofpVoPCEUjYIv454ZEZ3sLgMD17nIwlz2z7bsn7rl301Kt/01umFA7MscUovFfAK2IRGck6XB+uulMu6aFhKQ== - -"@swc/core-linux-x64-gnu@1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.14.0.tgz#953f741d577a81f6e1e1b434856c48eb674cdeb7" - integrity sha512-quTTx1Olm05fBfv66DEBuOsOgqdypnZ/1Bh3yGXWY7ANLFeeRpCDZpljD9BSjdsNdPOlwJmEUZXMHtGm3v1TZQ== - -"@swc/core-linux-x64-musl@1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.14.0.tgz#bdf241062d1433ba617ffe1451dccde8923a28a2" - integrity sha512-caaNAu+aIqT8seLtCf08i8C3/UC5ttQujUjejhMcuS1/LoCKtNiUs4VekJd2UGt+pyuuSrQ6dKl8CbCfWvWeXw== - -"@swc/core-win32-arm64-msvc@1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.14.0.tgz#960919015bc31c46a8fc10df5c384add651df91e" - integrity sha512-EeW3jFlT3YNckJ6V/JnTfGcX7UHGyh6/AiCPopZ1HNaGiXVCKHPpVQZicmtyr/UpqxCXLrTgjHOvyMke7YN26A== - -"@swc/core-win32-ia32-msvc@1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.14.0.tgz#826a76b2af0e4df4dee3674e91734cb85eb7b21f" - integrity sha512-dPai3KUIcihV5hfoO4QNQF5HAaw8+2bT7dvi8E5zLtecW2SfL3mUZipzampXq5FHll0RSCLzlrXnSx+dBRZIIQ== - -"@swc/core-win32-x64-msvc@1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.14.0.tgz#75fe708a702f57f176fd640eb9af394cf767be91" - integrity sha512-nm+JajGrTqUA6sEHdghDlHMNfH1WKSiuvljhdmBACW4ta4LC3gKurX2qZuiBARvPkephW9V/i5S8QPY1PzFEqg== +"@swc/core-darwin-arm64@1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.0.tgz#158a0890fb2546b4d57b99234c1033e4a38b62e2" + integrity sha512-TBKWkbnShnEjlIbO4/gfsrIgAqHBVqgPWLbWmPdZ80bF393yJcLgkrb7bZEnJs6FCbSSuGwZv2rx1jDR2zo6YA== + +"@swc/core-darwin-x64@1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.0.tgz#d03a71e60244f19ac921bf23c2cafc4122d76d8e" + integrity sha512-f5JKL1v1H56CIZc1pVn4RGPOfnWqPwmuHdpf4wesvXunF1Bx85YgcspW5YxwqG5J9g3nPU610UFuExJXVUzOiQ== + +"@swc/core-linux-arm-gnueabihf@1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.0.tgz#fe978712a8924c0555c6b248ad3b57912ba123fb" + integrity sha512-duK6nG+WyuunnfsfiTUQdzC9Fk8cyDLqT9zyXvY2i2YgDu5+BH5W6wM5O4mDNCU5MocyB/SuF5YDF7XySnowiQ== + +"@swc/core-linux-arm64-gnu@1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.0.tgz#a5dacdd857dec4ac2931820def17bc0e42c88ede" + integrity sha512-ITe9iDtTRXM98B91rvyPP6qDVbhUBnmA/j4UxrHlMQ0RlwpqTjfZYZkD0uclOxSZ6qIrOj/X5CaoJlDUuQ0+Cw== + +"@swc/core-linux-arm64-musl@1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.0.tgz#243643a7d22c8e2f334046c1d76f342ad4369be9" + integrity sha512-Q5ldc2bzriuzYEoAuqJ9Vr3FyZhakk5hiwDbniZ8tlEXpbjBhbOleGf9/gkhLaouDnkNUEazFW9mtqwUTRdh7Q== + +"@swc/core-linux-x64-gnu@1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.0.tgz#26936f55c916f65d33a4cf957c7573722f9eca54" + integrity sha512-pY4is+jEpOxlYCSnI+7N8Oxbap9TmTz5YT84tUvRTlOlTBwFAUlWFCX0FRwWJlsfP0TxbqhIe8dNNzlsEmJbXQ== + +"@swc/core-linux-x64-musl@1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.0.tgz#a7164c11ac86ed99a1d5d8bef86ec0fbe6235f6c" + integrity sha512-zYEt5eT8y8RUpoe7t5pjpoOdGu+/gSTExj8PV86efhj6ugB3bPlj3Y85ogdW3WMVXr4NvwqvzdaYGCZfXzSyVg== + +"@swc/core-win32-arm64-msvc@1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.0.tgz#645fe54564eab4224127672f2f4fe44876223af0" + integrity sha512-zC1rmOgFH5v2BCbByOazEqs0aRNpTdLRchDExfcCfgKgeaD+IdpUOqp7i3VG1YzkcnbuZjMlXfM0ugpt+CddoA== + +"@swc/core-win32-ia32-msvc@1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.0.tgz#fd70c8c8b542a52a88cda758fb82569d52ea949a" + integrity sha512-7t9U9KwMwQblkdJIH+zX1V4q1o3o41i0HNO+VlnAHT5o+5qHJ963PHKJ/pX3P2UlZnBCY465orJuflAN4rAP9A== + +"@swc/core-win32-x64-msvc@1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.0.tgz#1d4f06078c7dbf757c537dd08740472694257198" + integrity sha512-VE0Zod5vcs8iMLT64m5QS1DlTMXJFI/qSgtMDRx8rtZrnjt6/9NW8XUaiPJuRu8GluEO1hmHoyf1qlbY19gGSQ== "@swc/core@^1.13.5": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.14.0.tgz#ff7d287fbac6b6fd3adedf7b440cadfd0c389df6" - integrity sha512-oExhY90bes5pDTVrei0xlMVosTxwd/NMafIpqsC4dMbRYZ5KB981l/CX8tMnGsagTplj/RcG9BeRYmV6/J5m3w== + version "1.15.0" + resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.0.tgz#6ae4dbd5a164261ba799ccdf9eae3bbc61e112c2" + integrity sha512-8SnJV+JV0rYbfSiEiUvYOmf62E7QwsEG+aZueqSlKoxFt0pw333+bgZSQXGUV6etXU88nxur0afVMaINujBMSw== dependencies: "@swc/counter" "^0.1.3" "@swc/types" "^0.1.25" optionalDependencies: - "@swc/core-darwin-arm64" "1.14.0" - "@swc/core-darwin-x64" "1.14.0" - "@swc/core-linux-arm-gnueabihf" "1.14.0" - "@swc/core-linux-arm64-gnu" "1.14.0" - "@swc/core-linux-arm64-musl" "1.14.0" - "@swc/core-linux-x64-gnu" "1.14.0" - "@swc/core-linux-x64-musl" "1.14.0" - "@swc/core-win32-arm64-msvc" "1.14.0" - "@swc/core-win32-ia32-msvc" "1.14.0" - "@swc/core-win32-x64-msvc" "1.14.0" + "@swc/core-darwin-arm64" "1.15.0" + "@swc/core-darwin-x64" "1.15.0" + "@swc/core-linux-arm-gnueabihf" "1.15.0" + "@swc/core-linux-arm64-gnu" "1.15.0" + "@swc/core-linux-arm64-musl" "1.15.0" + "@swc/core-linux-x64-gnu" "1.15.0" + "@swc/core-linux-x64-musl" "1.15.0" + "@swc/core-win32-arm64-msvc" "1.15.0" + "@swc/core-win32-ia32-msvc" "1.15.0" + "@swc/core-win32-x64-msvc" "1.15.0" "@swc/counter@^0.1.3": version "0.1.3" @@ -1470,7 +1301,7 @@ picocolors "1.1.1" pretty-format "^27.0.2" -"@testing-library/jest-dom@^6.8.0": +"@testing-library/jest-dom@^6.9.1": version "6.9.1" resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz#7613a04e146dd2976d24ddf019730d57a89d56c2" integrity sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA== @@ -1533,11 +1364,12 @@ "@babel/types" "^7.28.2" "@types/chai@^5.2.2": - version "5.2.2" - resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.2.tgz#6f14cea18180ffc4416bc0fd12be05fdd73bdd6b" - integrity sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg== + version "5.2.3" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" + integrity sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== dependencies: "@types/deep-eql" "*" + assertion-error "^2.0.1" "@types/deep-eql@*": version "4.0.2" @@ -1549,12 +1381,22 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== +"@types/js-yaml@^4.0.9": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.9.tgz#cd82382c4f902fed9691a2ed79ec68c5898af4c2" + integrity sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg== + "@types/json-schema@^7.0.15": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== -"@types/node@^22.17.0": +"@types/lodash@^4.17.20": + version "4.17.20" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.20.tgz#1ca77361d7363432d29f5e55950d9ec1e1c6ea93" + integrity sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA== + +"@types/node@^22.19.0": version "22.19.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.0.tgz#849606ef3920850583a4e7ee0930987c35ad80be" integrity sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA== @@ -1571,7 +1413,7 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7" integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== -"@types/react-dom@^19.1.7": +"@types/react-dom@^19.2.2": version "19.2.2" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.2.tgz#a4cc874797b7ddc9cb180ef0d5dc23f596fc2332" integrity sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw== @@ -1589,7 +1431,7 @@ resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.12.tgz#b5d76568485b02a307238270bfe96cb51ee2a044" integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w== -"@types/react@*", "@types/react@^19.1.11": +"@types/react@*", "@types/react@^19.2.2": version "19.2.2" resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.2.tgz#ba123a75d4c2a51158697160a4ea2ff70aa6bf36" integrity sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA== @@ -1606,79 +1448,79 @@ resolved "https://registry.yarnpkg.com/@types/whatwg-streams/-/whatwg-streams-0.0.7.tgz#28bfe73dc850562296367249c4b32a50db81e9d3" integrity sha512-6sDiSEP6DWcY2ZolsJ2s39ZmsoGQ7KVwBDI3sESQsEm9P2dHTcqnDIHRZFRNtLCzWp7hCFGqYbw5GyfpQnJ01A== -"@typescript-eslint/eslint-plugin@^8.40.0": - version "8.46.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz#dc4ab93ee3d7e6c8e38820a0d6c7c93c7183e2dc" - integrity sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w== +"@typescript-eslint/eslint-plugin@^8.46.3": + version "8.46.3" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz#6f7aeaf9f5c611425db9b8f983e8d3fe5deece3c" + integrity sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw== dependencies: "@eslint-community/regexpp" "^4.10.0" - "@typescript-eslint/scope-manager" "8.46.2" - "@typescript-eslint/type-utils" "8.46.2" - "@typescript-eslint/utils" "8.46.2" - "@typescript-eslint/visitor-keys" "8.46.2" + "@typescript-eslint/scope-manager" "8.46.3" + "@typescript-eslint/type-utils" "8.46.3" + "@typescript-eslint/utils" "8.46.3" + "@typescript-eslint/visitor-keys" "8.46.3" graphemer "^1.4.0" ignore "^7.0.0" natural-compare "^1.4.0" ts-api-utils "^2.1.0" -"@typescript-eslint/parser@^8.40.0": - version "8.46.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.46.2.tgz#dd938d45d581ac8ffa9d8a418a50282b306f7ebf" - integrity sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g== +"@typescript-eslint/parser@^8.46.3": + version "8.46.3" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.46.3.tgz#3badfb62d2e2dc733d02a038073e3f65f2cb833d" + integrity sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg== dependencies: - "@typescript-eslint/scope-manager" "8.46.2" - "@typescript-eslint/types" "8.46.2" - "@typescript-eslint/typescript-estree" "8.46.2" - "@typescript-eslint/visitor-keys" "8.46.2" + "@typescript-eslint/scope-manager" "8.46.3" + "@typescript-eslint/types" "8.46.3" + "@typescript-eslint/typescript-estree" "8.46.3" + "@typescript-eslint/visitor-keys" "8.46.3" debug "^4.3.4" -"@typescript-eslint/project-service@8.46.2": - version "8.46.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.46.2.tgz#ab2f02a0de4da6a7eeb885af5e059be57819d608" - integrity sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg== +"@typescript-eslint/project-service@8.46.3": + version "8.46.3" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.46.3.tgz#4555c685407ea829081218fa033d7b032607aaef" + integrity sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ== dependencies: - "@typescript-eslint/tsconfig-utils" "^8.46.2" - "@typescript-eslint/types" "^8.46.2" + "@typescript-eslint/tsconfig-utils" "^8.46.3" + "@typescript-eslint/types" "^8.46.3" debug "^4.3.4" -"@typescript-eslint/scope-manager@8.46.2": - version "8.46.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz#7d37df2493c404450589acb3b5d0c69cc0670a88" - integrity sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA== +"@typescript-eslint/scope-manager@8.46.3": + version "8.46.3" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.46.3.tgz#2e330f566e135ccac13477b98dd88d8f176e4dff" + integrity sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg== dependencies: - "@typescript-eslint/types" "8.46.2" - "@typescript-eslint/visitor-keys" "8.46.2" + "@typescript-eslint/types" "8.46.3" + "@typescript-eslint/visitor-keys" "8.46.3" -"@typescript-eslint/tsconfig-utils@8.46.2", "@typescript-eslint/tsconfig-utils@^8.46.2": - version "8.46.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz#d110451cb93bbd189865206ea37ef677c196828c" - integrity sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag== +"@typescript-eslint/tsconfig-utils@8.46.3", "@typescript-eslint/tsconfig-utils@^8.46.3": + version "8.46.3" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.3.tgz#cad33398c762c97fe56a8defda00c16505abefa3" + integrity sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA== -"@typescript-eslint/type-utils@8.46.2": - version "8.46.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz#802d027864e6fb752e65425ed09f3e089fb4d384" - integrity sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA== +"@typescript-eslint/type-utils@8.46.3": + version "8.46.3" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.46.3.tgz#71188df833d7697ecff256cd1d3889a20552d78c" + integrity sha512-ZPCADbr+qfz3aiTTYNNkCbUt+cjNwI/5McyANNrFBpVxPt7GqpEYz5ZfdwuFyGUnJ9FdDXbGODUu6iRCI6XRXw== dependencies: - "@typescript-eslint/types" "8.46.2" - "@typescript-eslint/typescript-estree" "8.46.2" - "@typescript-eslint/utils" "8.46.2" + "@typescript-eslint/types" "8.46.3" + "@typescript-eslint/typescript-estree" "8.46.3" + "@typescript-eslint/utils" "8.46.3" debug "^4.3.4" ts-api-utils "^2.1.0" -"@typescript-eslint/types@8.46.2", "@typescript-eslint/types@^8.46.2": - version "8.46.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.46.2.tgz#2bad7348511b31e6e42579820e62b73145635763" - integrity sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ== +"@typescript-eslint/types@8.46.3", "@typescript-eslint/types@^8.46.3": + version "8.46.3" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.46.3.tgz#da05ea40e91359b4275dbb3a489f2f7907a02245" + integrity sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA== -"@typescript-eslint/typescript-estree@8.46.2": - version "8.46.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz#ab547a27e4222bb6a3281cb7e98705272e2c7d08" - integrity sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ== +"@typescript-eslint/typescript-estree@8.46.3": + version "8.46.3" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.3.tgz#c12406afba707f9779ce0c0151a08c33b3a96d41" + integrity sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA== dependencies: - "@typescript-eslint/project-service" "8.46.2" - "@typescript-eslint/tsconfig-utils" "8.46.2" - "@typescript-eslint/types" "8.46.2" - "@typescript-eslint/visitor-keys" "8.46.2" + "@typescript-eslint/project-service" "8.46.3" + "@typescript-eslint/tsconfig-utils" "8.46.3" + "@typescript-eslint/types" "8.46.3" + "@typescript-eslint/visitor-keys" "8.46.3" debug "^4.3.4" fast-glob "^3.3.2" is-glob "^4.0.3" @@ -1686,25 +1528,25 @@ semver "^7.6.0" ts-api-utils "^2.1.0" -"@typescript-eslint/utils@8.46.2": - version "8.46.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.46.2.tgz#b313d33d67f9918583af205bd7bcebf20f231732" - integrity sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg== +"@typescript-eslint/utils@8.46.3": + version "8.46.3" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.46.3.tgz#b6c7994b7c1ee2fe338ab32f7b3d4424856a73ce" + integrity sha512-VXw7qmdkucEx9WkmR3ld/u6VhRyKeiF1uxWwCy/iuNfokjJ7VhsgLSOTjsol8BunSw190zABzpwdNsze2Kpo4g== dependencies: "@eslint-community/eslint-utils" "^4.7.0" - "@typescript-eslint/scope-manager" "8.46.2" - "@typescript-eslint/types" "8.46.2" - "@typescript-eslint/typescript-estree" "8.46.2" + "@typescript-eslint/scope-manager" "8.46.3" + "@typescript-eslint/types" "8.46.3" + "@typescript-eslint/typescript-estree" "8.46.3" -"@typescript-eslint/visitor-keys@8.46.2": - version "8.46.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz#803fa298948c39acf810af21bdce6f8babfa9738" - integrity sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w== +"@typescript-eslint/visitor-keys@8.46.3": + version "8.46.3" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.3.tgz#6811b15053501981059c58e1c01b39242bd5c0f6" + integrity sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg== dependencies: - "@typescript-eslint/types" "8.46.2" + "@typescript-eslint/types" "8.46.3" eslint-visitor-keys "^4.2.1" -"@vitejs/plugin-react-swc@^4.0.1": +"@vitejs/plugin-react-swc@^4.2.0": version "4.2.0" resolved "https://registry.yarnpkg.com/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.0.tgz#91c893b76018e32166ca0b9db0c7b1ef67e04595" integrity sha512-/tesahXD1qpkGC6FzMoFOJj0RyZdw9xLELOL+6jbElwmWfwOnIVy+IfpY+o9JfD9PKaR/Eyb6DNrvbXpuvA+8Q== @@ -1712,7 +1554,7 @@ "@rolldown/pluginutils" "1.0.0-beta.43" "@swc/core" "^1.13.5" -"@vitejs/plugin-react@^5.0.1": +"@vitejs/plugin-react@^5.1.0": version "5.1.0" resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.1.0.tgz#1f37671a227571437d6e324b824256dac157570e" integrity sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew== @@ -1981,9 +1823,9 @@ balanced-match@^1.0.0: integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== baseline-browser-mapping@^2.8.19: - version "2.8.23" - resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.23.tgz#cd43e17eff5cbfb67c92153e7fe856cf6d426421" - integrity sha512-616V5YX4bepJFzNyOfce5Fa8fDJMfoxzOIzDCZwaGL8MKVpFrXqfNUoIpRn9YMI5pXf/VKgzjB4htFMsFKKdiQ== + version "2.8.24" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.24.tgz#f70388d8a136b701c819567f6798b797378be7b0" + integrity sha512-uUhTRDPXamakPyghwrUcjaGvvBqGrWvBHReoiULMIpOJVM9IYzQh83Xk2Onx5HlGI2o10NNCzcs9TG/S3TkwrQ== brace-expansion@^1.1.7: version "1.1.12" @@ -2007,7 +1849,7 @@ braces@^3.0.3: dependencies: fill-range "^7.1.1" -browserslist@^4.24.0: +browserslist@^4.24.0, browserslist@^4.24.4: version "4.27.0" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.27.0.tgz#755654744feae978fbb123718b2f139bc0fa6697" integrity sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw== @@ -2018,16 +1860,6 @@ browserslist@^4.24.0: node-releases "^2.0.26" update-browserslist-db "^1.1.4" -browserslist@^4.24.4: - version "4.25.3" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.25.3.tgz#9167c9cbb40473f15f75f85189290678b99b16c5" - integrity sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ== - dependencies: - caniuse-lite "^1.0.30001735" - electron-to-chromium "^1.5.204" - node-releases "^2.0.19" - update-browserslist-db "^1.1.3" - buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" @@ -2079,17 +1911,7 @@ camelcase@^6.2.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== -caniuse-lite@^1.0.30001702: - version "1.0.30001737" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001737.tgz#8292bb7591932ff09e9a765f12fdf5629a241ccc" - integrity sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw== - -caniuse-lite@^1.0.30001735: - version "1.0.30001741" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz#67fb92953edc536442f3c9da74320774aa523143" - integrity sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw== - -caniuse-lite@^1.0.30001751: +caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001751: version "1.0.30001753" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001753.tgz#419f8fc9bab6f1a1d10d9574d0b3374f823c5b00" integrity sha512-Bj5H35MD/ebaOV4iDLqPEtiliTN29qkGtEHCwawWn4cYm+bPJM2NsaP30vtZcnERClMzp52J4+aw2UNbK4o+zw== @@ -2276,14 +2098,7 @@ date-fns@^4.1.0: resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14" integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg== -debug@4, debug@^4.4.1: - version "4.4.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" - integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== - dependencies: - ms "^2.1.3" - -debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: +debug@4, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.1: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -2380,11 +2195,6 @@ dunder-proto@^1.0.0, dunder-proto@^1.0.1: es-errors "^1.3.0" gopd "^1.2.0" -electron-to-chromium@^1.5.204: - version "1.5.214" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.214.tgz#f7bbdc0796124292d4b8a34a49e968c5e6430763" - integrity sha512-TpvUNdha+X3ybfU78NoQatKvQEm1oq3lf2QbnmCEdw+Bd9RuIAY+hJTvq1avzHM0f7EJfnH3vbCnbzKzisc/9Q== - electron-to-chromium@^1.5.238: version "1.5.244" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.244.tgz#b9b61e3d24ef4203489951468614f2a360763820" @@ -2406,9 +2216,9 @@ entities@^6.0.0: integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g== error-ex@^1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" - integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== + version "1.3.4" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.4.tgz#b3a8d8bb6f92eecc1629e3e27d3c8607a8a32414" + integrity sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ== dependencies: is-arrayish "^0.2.1" @@ -2560,7 +2370,7 @@ esbuild-runner@^2.2.2: source-map-support "0.5.21" tslib "2.4.0" -esbuild@^0.25.0: +esbuild@^0.25.0, esbuild@^0.25.12: version "0.25.12" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.12.tgz#97a1d041f4ab00c2fce2f838d2b9969a2d2a97a5" integrity sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg== @@ -2592,38 +2402,6 @@ esbuild@^0.25.0: "@esbuild/win32-ia32" "0.25.12" "@esbuild/win32-x64" "0.25.12" -esbuild@^0.25.9: - version "0.25.11" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.11.tgz#0f31b82f335652580f75ef6897bba81962d9ae3d" - integrity sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q== - optionalDependencies: - "@esbuild/aix-ppc64" "0.25.11" - "@esbuild/android-arm" "0.25.11" - "@esbuild/android-arm64" "0.25.11" - "@esbuild/android-x64" "0.25.11" - "@esbuild/darwin-arm64" "0.25.11" - "@esbuild/darwin-x64" "0.25.11" - "@esbuild/freebsd-arm64" "0.25.11" - "@esbuild/freebsd-x64" "0.25.11" - "@esbuild/linux-arm" "0.25.11" - "@esbuild/linux-arm64" "0.25.11" - "@esbuild/linux-ia32" "0.25.11" - "@esbuild/linux-loong64" "0.25.11" - "@esbuild/linux-mips64el" "0.25.11" - "@esbuild/linux-ppc64" "0.25.11" - "@esbuild/linux-riscv64" "0.25.11" - "@esbuild/linux-s390x" "0.25.11" - "@esbuild/linux-x64" "0.25.11" - "@esbuild/netbsd-arm64" "0.25.11" - "@esbuild/netbsd-x64" "0.25.11" - "@esbuild/openbsd-arm64" "0.25.11" - "@esbuild/openbsd-x64" "0.25.11" - "@esbuild/openharmony-arm64" "0.25.11" - "@esbuild/sunos-x64" "0.25.11" - "@esbuild/win32-arm64" "0.25.11" - "@esbuild/win32-ia32" "0.25.11" - "@esbuild/win32-x64" "0.25.11" - escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" @@ -2639,7 +2417,7 @@ eslint-plugin-react-hooks@^5.2.0: resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz#1be0080901e6ac31ce7971beed3d3ec0a423d9e3" integrity sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg== -eslint-plugin-react-refresh@^0.4.20: +eslint-plugin-react-refresh@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz#6914e8757eb7d7ccc3efb9dbcc8a51feda71d89e" integrity sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w== @@ -2686,7 +2464,7 @@ eslint-visitor-keys@^4.2.1: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== -eslint@^9.34.0: +eslint@^9.39.1: version "9.39.1" resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.1.tgz#be8bf7c6de77dcc4252b5a8dcb31c2efff74a6e5" integrity sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g== @@ -2908,6 +2686,11 @@ functions-have-names@^1.2.3: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== +generator-function@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/generator-function/-/generator-function-2.0.1.tgz#0e75dd410d1243687a0ba2e951b94eedb8f737a2" + integrity sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g== + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -3097,7 +2880,7 @@ i18next-http-backend@^3.0.2: dependencies: cross-fetch "4.0.0" -i18next@^25.4.0: +i18next@^25.6.0: version "25.6.0" resolved "https://registry.yarnpkg.com/i18next/-/i18next-25.6.0.tgz#d1ed719b35af9db619738e9ce7408b75d6b7957f" integrity sha512-tTn8fLrwBYtnclpL5aPXK/tAYBLWVvoHM1zdfXoRNLcI+RvtMsoZRV98ePlaW3khHYKuNh/Q65W/+NVFUeIwVw== @@ -3126,21 +2909,16 @@ immer@^10.2.0: resolved "https://registry.yarnpkg.com/immer/-/immer-10.2.0.tgz#88a4ce06a1af64172d254b70f7cb04df51c871b1" integrity sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw== -immutable@>=3.8.2: - version "5.1.3" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.3.tgz#e6486694c8b76c37c063cca92399fa64098634d4" - integrity sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg== +immutable@>=3.8.2, immutable@^5.0.2: + version "5.1.4" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.4.tgz#e3f8c1fe7b567d56cf26698f31918c241dae8c1f" + integrity sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA== immutable@^3.8.2: version "3.8.2" resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" integrity sha512-15gZoQ38eYjEjxkorfbcgBKBL6R7T459OuK+CpcWt7O3KF4uPCx2tD0uFETlUDIyo+1789crbMhTvQBSR5yBMg== -immutable@^5.0.2: - version "5.1.4" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.4.tgz#e3f8c1fe7b567d56cf26698f31918c241dae8c1f" - integrity sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA== - import-fresh@^3.2.1, import-fresh@^3.3.0: version "3.3.1" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" @@ -3218,7 +2996,7 @@ is-callable@^1.2.7: resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== -is-core-module@^2.13.0, is-core-module@^2.16.0: +is-core-module@^2.13.0, is-core-module@^2.16.1: version "2.16.1" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== @@ -3260,12 +3038,13 @@ is-fullwidth-code-point@^3.0.0: integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== is-generator-function@^1.0.10: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.1.0.tgz#bf3eeda931201394f57b5dba2800f91a238309ca" - integrity sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ== + version "1.1.2" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.1.2.tgz#ae3b61e3d5ea4e4839b90bad22b02335051a17d5" + integrity sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA== dependencies: - call-bound "^1.0.3" - get-proto "^1.0.0" + call-bound "^1.0.4" + generator-function "^2.0.0" + get-proto "^1.0.1" has-tostringtag "^1.0.2" safe-regex-test "^1.1.0" @@ -3559,9 +3338,9 @@ lz-string@^1.5.0: integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== magic-string@^0.30.17: - version "0.30.18" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.18.tgz#905bfbbc6aa5692703a93db26a9edcaa0007d2bb" - integrity sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ== + version "0.30.21" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== dependencies: "@jridgewell/sourcemap-codec" "^1.5.5" @@ -3684,11 +3463,6 @@ node-readfiles@^0.2.0: dependencies: es6-promise "^3.2.1" -node-releases@^2.0.19: - version "2.0.21" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.21.tgz#f59b018bc0048044be2d4c4c04e4c8b18160894c" - integrity sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw== - node-releases@^2.0.26: version "2.0.27" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e" @@ -3705,9 +3479,9 @@ normalize.css@^8.0.1: integrity sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg== nwsapi@^2.2.16: - version "2.2.21" - resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.21.tgz#8df7797079350adda208910d8c33fc4c2d7520c3" - integrity sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA== + version "2.2.22" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.22.tgz#109f9530cda6c156d6a713cdf5939e9f0de98b9d" + integrity sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ== oas-kit-common@^1.0.8: version "1.0.8" @@ -3995,7 +3769,7 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -react-dom@^19.1.1: +react-dom@^19.2.0: version "19.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.0.tgz#00ed1e959c365e9a9d48f8918377465466ec3af8" integrity sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ== @@ -4009,12 +3783,12 @@ react-error-boundary@^6.0.0: dependencies: "@babel/runtime" "^7.12.5" -react-hook-form@^7.62.0: +react-hook-form@^7.66.0: version "7.66.0" resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.66.0.tgz#1a09ea9d0ebb3bdda5073b08a486538d37d9c0d4" integrity sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw== -react-i18next@^15.7.1: +react-i18next@^15.7.4: version "15.7.4" resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-15.7.4.tgz#146e50f220d204b842e22c75d1a3d23c6c589a30" integrity sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw== @@ -4032,7 +3806,7 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-is@^19.1.1: +react-is@^19.2.0: version "19.2.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.0.tgz#ddc3b4a4e0f3336c3847f18b806506388d7b9973" integrity sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA== @@ -4070,7 +3844,7 @@ react-refresh@^0.18.0: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.18.0.tgz#2dce97f4fe932a4d8142fa1630e475c1729c8062" integrity sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw== -react-router-dom@^7.8.2: +react-router-dom@^7.9.5: version "7.9.5" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.9.5.tgz#99a88cde83919bdfc84fbb3d6bf7c6fc18ca0758" integrity sha512-mkEmq/K8tKN63Ae2M7Xgz3c9l9YNbY+NHH6NNeUmLA3kDkhKXRsNb/ZpxaEunvGo2/3YXdk5EJU3Hxp3ocaBPw== @@ -4121,7 +3895,7 @@ react-virtualized@^9.21.0: prop-types "^15.7.2" react-lifecycles-compat "^3.0.4" -react@^19.1.1: +react@^19.2.0: version "19.2.0" resolved "https://registry.yarnpkg.com/react/-/react-19.2.0.tgz#d33dd1721698f4376ae57a54098cb47fc75d93a5" integrity sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ== @@ -4206,11 +3980,11 @@ resolve-from@^4.0.0: integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== resolve@^1.19.0: - version "1.22.10" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" - integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== + version "1.22.11" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262" + integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ== dependencies: - is-core-module "^2.16.0" + is-core-module "^2.16.1" path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" @@ -4304,7 +4078,7 @@ safe-regex-test@^1.1.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -sass@^1.90.0: +sass@^1.93.3: version "1.93.3" resolved "https://registry.yarnpkg.com/sass/-/sass-1.93.3.tgz#3ff0aa5879dc910d32eae10c282a2847bd63e758" integrity sha512-elOcIZRTM76dvxNAjqYrucTSI0teAF/L2Lv0s6f6b7FOwcwIuA357bIE871580AjHJuSvLIRUosgV+lIWx6Rgg== @@ -4516,9 +4290,9 @@ state-local@^1.0.6: integrity sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w== std-env@^3.9.0: - version "3.9.0" - resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.9.0.tgz#1a6f7243b339dca4c9fd55e1c7504c77ef23e8f1" - integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw== + version "3.10.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b" + integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== stop-iteration-iterator@^1.1.0: version "1.1.0" @@ -4616,9 +4390,9 @@ strip-json-comments@^3.1.1: integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== strip-literal@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-3.0.0.tgz#ce9c452a91a0af2876ed1ae4e583539a353df3fc" - integrity sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA== + version "3.1.0" + resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-3.1.0.tgz#222b243dd2d49c0bcd0de8906adbd84177196032" + integrity sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg== dependencies: js-tokens "^9.0.1" @@ -4661,6 +4435,11 @@ swagger2openapi@^7.0.4, swagger2openapi@^7.0.8: yaml "^1.10.0" yargs "^17.0.1" +swiper@^12.0.3: + version "12.0.3" + resolved "https://registry.yarnpkg.com/swiper/-/swiper-12.0.3.tgz#c7804fd39955189d3ec652d9832896bd3d04733e" + integrity sha512-BHd6U1VPEIksrXlyXjMmRWO0onmdNPaTAFduzqR3pgjvi7KfmUCAm/0cj49u2D7B0zNjMw02TSeXfinC1hDCXg== + symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" @@ -4710,9 +4489,9 @@ tinyrainbow@^2.0.0: integrity sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw== tinyspy@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-4.0.3.tgz#d1d0f0602f4c15f1aae083a34d6d0df3363b1b52" - integrity sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A== + version "4.0.4" + resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-4.0.4.tgz#d77a002fb53a88aa1429b419c1c92492e0c81f78" + integrity sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q== tldts-core@^6.1.86: version "6.1.86" @@ -4829,7 +4608,7 @@ typed-array-length@^1.0.7: possible-typed-array-names "^1.0.0" reflect.getprototypeof "^1.0.6" -typescript@^5.8.2, typescript@^5.8.3, typescript@^5.9.2: +typescript@^5.8.2, typescript@^5.8.3, typescript@^5.9.3: version "5.9.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== @@ -4849,7 +4628,7 @@ undici-types@~6.21.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== -update-browserslist-db@^1.1.3, update-browserslist-db@^1.1.4: +update-browserslist-db@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz#7802aa2ae91477f255b86e0e46dbc787a206ad4a" integrity sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A== @@ -4864,12 +4643,7 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -use-sync-external-store@^1.4.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#55122e2a3edd2a6c106174c27485e0fd59bcfca0" - integrity sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A== - -use-sync-external-store@^1.6.0: +use-sync-external-store@^1.4.0, use-sync-external-store@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== @@ -4885,7 +4659,7 @@ vite-node@3.2.4: pathe "^2.0.3" vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" -vite-plugin-svgr@^4.3.0: +vite-plugin-svgr@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/vite-plugin-svgr/-/vite-plugin-svgr-4.5.0.tgz#253e4c703d1f0b30935c285ca8621f4857952338" integrity sha512-W+uoSpmVkSmNOGPSsDCWVW/DDAyv+9fap9AZXBvWiQqrboJ08j2vh0tFxTD/LjwqwAd3yYSVJgm54S/1GhbdnA== @@ -4894,7 +4668,7 @@ vite-plugin-svgr@^4.3.0: "@svgr/core" "^8.1.0" "@svgr/plugin-jsx" "^8.1.0" -"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^7.1.3: +"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0": version "7.1.12" resolved "https://registry.yarnpkg.com/vite/-/vite-7.1.12.tgz#8b29a3f61eba23bcb93fc9ec9af4a3a1e83eecdb" integrity sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug== @@ -4908,6 +4682,20 @@ vite-plugin-svgr@^4.3.0: optionalDependencies: fsevents "~2.3.3" +vite@^7.2.2: + version "7.2.2" + resolved "https://registry.yarnpkg.com/vite/-/vite-7.2.2.tgz#17dd62eac2d0ca0fa90131c5f56e4fefb8845362" + integrity sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ== + dependencies: + esbuild "^0.25.0" + fdir "^6.5.0" + picomatch "^4.0.3" + postcss "^8.5.6" + rollup "^4.43.0" + tinyglobby "^0.2.15" + optionalDependencies: + fsevents "~2.3.3" + vitest@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/vitest/-/vitest-3.2.4.tgz#0637b903ad79d1539a25bc34c0ed54b5c67702ea" @@ -5104,6 +4892,11 @@ yaml@^1.10.0: resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== +yaml@^2.8.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.1.tgz#1870aa02b631f7e8328b93f8bc574fac5d6c4d79" + integrity sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw== + yargs-parser@^21.1.1: version "21.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" @@ -5127,7 +4920,7 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -yup@^1.7.0: +yup@^1.7.1: version "1.7.1" resolved "https://registry.yarnpkg.com/yup/-/yup-1.7.1.tgz#4c47c6bb367df08d4bc597f8c4c4f5fc4277f6ab" integrity sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==