diff --git a/.config/make/docker.mak b/.config/make/docker.mak index 1d66aea45..5366f5364 100644 --- a/.config/make/docker.mak +++ b/.config/make/docker.mak @@ -2,22 +2,89 @@ TAG ?= local DOCKER_REGISTRY ?= vitabaks -.PHONY: docker-build -docker-build: ## Run docker build image (example: make docker-build TAG=my_tag) - @echo "Building container image with tag $(TAG)"; - docker build --no-cache --tag postgresql_cluster:$(TAG) --file Dockerfile . - -.PHONY: docker-push -docker-push: ## Push image to Dockerhub (example: make docker-push TAG=my_tag DOCKER_REGISTRY=my_repo DOCKER_REGISTRY_USER="my_username" DOCKER_REGISTRY_PASSWORD="my_password") - @echo "Pushing container image with tag $(TAG)"; +.PHONY: docker-lint docker-lint-console-ui docker-lint-console-api docker-lint-console-db docker-lint-console +docker-lint: docker-lint-automation docker-lint-console-ui docker-lint-console-api docker-lint-console-db docker-lint-console ## Lint all Dockerfiles + +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 + +docker-lint-console-ui: ## Lint console ui Dockerfile + @echo "Lint console ui container Dockerfile" + docker run --rm -i -v $(PWD)/console/ui/Dockerfile:/Dockerfile \ + hadolint/hadolint hadolint --ignore DL3002 --ignore DL3008 --ignore DL3059 /Dockerfile + +docker-lint-console-api: ## Lint console api Dockerfile + @echo "Lint console api container Dockerfile" + docker run --rm -i -v $(PWD)/console/service/Dockerfile:/Dockerfile \ + hadolint/hadolint hadolint --ignore DL3002 --ignore DL3008 --ignore DL3059 /Dockerfile + +docker-lint-console-db: ## Lint console db Dockerfile + @echo "Lint console db container Dockerfile" + docker run --rm -i -v $(PWD)/console/db/Dockerfile:/Dockerfile \ + hadolint/hadolint hadolint --ignore DL3002 --ignore DL3008 --ignore DL3059 --ignore DL4001 /Dockerfile + +docker-lint-console: ## Lint console Dockerfile (all services) + @echo "Lint console container Dockerfile" + docker run --rm -i -v $(PWD)/console/Dockerfile:/Dockerfile \ + hadolint/hadolint hadolint --ignore DL3002 --ignore DL3008 --ignore DL3059 --ignore DL4001 /Dockerfile + +.PHONY: docker-build docker-build-console-ui docker-build-console-api docker-build-console-db docker-build-console +docker-build: docker-build-automation docker-build-console-ui docker-build-console-api docker-build-console-db docker-build-console ## Build for all Docker images + +docker-build-automation: ## Build automation image + @echo "Build automation docker image with tag $(TAG)"; + docker build --no-cache --platform linux/amd64 --tag postgresql_cluster:$(TAG) --file automation/Dockerfile . + +docker-build-console-ui: ## Build console ui image + @echo "Build console ui docker image with tag $(TAG)" + docker build --no-cache --platform linux/amd64 --tag postgresql_cluster_console_ui:$(TAG) --file console/ui/Dockerfile . + +docker-build-console-api: ## Build console api image + @echo "Build console api docker image with tag $(TAG)" + docker build --no-cache --platform linux/amd64 --tag postgresql_cluster_console_api:$(TAG) --file console/service/Dockerfile . + +docker-build-console-db: ## Build console db image + @echo "Build console db docker image with tag $(TAG)" + docker build --no-cache --platform linux/amd64 --tag postgresql_cluster_console_db:$(TAG) --file console/db/Dockerfile . + +docker-build-console: ## Build console image (all services) + @echo "Build console docker image with tag $(TAG)" + docker build --no-cache --platform linux/amd64 --tag postgresql_cluster_console:$(TAG) --file console/Dockerfile . + +.PHONY: docker-push docker-push-console-ui docker-push-console-api docker-push-console-db docker-push-console +docker-push: docker-push-automation docker-push-console-ui docker-push-console-api docker-push-console-db docker-push-console ## Push all images to Dockerhub (example: make docker-push TAG=my_tag DOCKER_REGISTRY=my_repo DOCKER_REGISTRY_USER="my_username" DOCKER_REGISTRY_PASSWORD="my_password") + +docker-push-automation: ## Push automation to Dockerhub + @echo "Push automation docker image with tag $(TAG)"; echo "$(DOCKER_REGISTRY_PASSWORD)" | docker login --username "$(DOCKER_REGISTRY_USER)" --password-stdin docker tag postgresql_cluster:$(TAG) $(DOCKER_REGISTRY)/postgresql_cluster:$(TAG) docker push $(DOCKER_REGISTRY)/postgresql_cluster:$(TAG) -.PHONY: docker-lint -docker-lint: ## Run hadolint command to lint Dokerfile - docker run --rm -i -v ./Dockerfile:/Dockerfile \ - hadolint/hadolint hadolint --ignore DL3002 --ignore DL3008 --ignore DL3059 /Dockerfile +docker-push-console-ui: ## Push console ui image to Dockerhub + @echo "Push console ui docker image with tag $(TAG)" + echo "$(DOCKER_REGISTRY_PASSWORD)" | docker login --username "$(DOCKER_REGISTRY_USER)" --password-stdin + docker tag postgresql_cluster_console_ui:$(TAG) $(DOCKER_REGISTRY)/postgresql_cluster_console_ui:$(TAG) + docker push $(DOCKER_REGISTRY)/postgresql_cluster_console_ui:$(TAG) + +docker-push-console-api: ## Push console api image to Dockerhub + @echo "Push console api docker image with tag $(TAG)" + echo "$(DOCKER_REGISTRY_PASSWORD)" | docker login --username "$(DOCKER_REGISTRY_USER)" --password-stdin + docker tag postgresql_cluster_console_api:$(TAG) $(DOCKER_REGISTRY)/postgresql_cluster_console_api:$(TAG) + docker push $(DOCKER_REGISTRY)/postgresql_cluster_console_api:$(TAG) + +docker-push-console-db: ## Push console db image to Dockerhub + @echo "Push console db docker image with tag $(TAG)" + echo "$(DOCKER_REGISTRY_PASSWORD)" | docker login --username "$(DOCKER_REGISTRY_USER)" --password-stdin + docker tag postgresql_cluster_console_db:$(TAG) $(DOCKER_REGISTRY)/postgresql_cluster_console_db:$(TAG) + docker push $(DOCKER_REGISTRY)/postgresql_cluster_console_db:$(TAG) + +docker-push-console: ## Push console image to Dockerhub (all services) + @echo "Push console docker image with tag $(TAG)" + echo "$(DOCKER_REGISTRY_PASSWORD)" | docker login --username "$(DOCKER_REGISTRY_USER)" --password-stdin + docker tag postgresql_cluster_console:$(TAG) $(DOCKER_REGISTRY)/postgresql_cluster_console:$(TAG) + docker push $(DOCKER_REGISTRY)/postgresql_cluster_console:$(TAG) .PHONY: docker-tests docker-tests: ## Run tests for docker diff --git a/.config/make/linters.mak b/.config/make/linters.mak index 94775c165..ada449820 100644 --- a/.config/make/linters.mak +++ b/.config/make/linters.mak @@ -17,7 +17,7 @@ linter-yamllint: ## Lint YAML files using yamllint linter-ansible-lint: ## Lint Ansible files using ansible-lint echo "ansible-lint #########################################################" $(ACTIVATE_VENV) && \ - ansible-lint --force-color --parseable + ansible-lint --force-color --parseable ./automation .PHONY: linter-flake8 linter-flake8: ## Lint Python files using flake8 diff --git a/.config/make/molecule.mak b/.config/make/molecule.mak index 11915f133..61572edfa 100644 --- a/.config/make/molecule.mak +++ b/.config/make/molecule.mak @@ -5,52 +5,52 @@ ACTIVATE_VENV = . .venv/bin/activate .PHONY: molecule-test molecule-test: ## Run test sequence for default scenario - $(ACTIVATE_VENV) && molecule test + $(ACTIVATE_VENV) && cd automation && molecule test .PHONY: molecule-destroy molecule-destroy: ## Run destroy sequence for default scenario - $(ACTIVATE_VENV) && molecule destroy + $(ACTIVATE_VENV) && cd automation && molecule destroy .PHONY: molecule-converge molecule-converge: ## Run converge sequence for default scenario - $(ACTIVATE_VENV) && molecule converge + $(ACTIVATE_VENV) && cd automation && molecule converge .PHONY: molecule-reconverge molecule-reconverge: ## Run destroy and converge sequence for default scenario - $(ACTIVATE_VENV) && molecule destroy && molecule converge + $(ACTIVATE_VENV) && cd automation && molecule destroy && && molecule converge .PHONY: molecule-test-all molecule-test-all: ## Run test sequence for all scenarios - $(ACTIVATE_VENV) && molecule test --all + $(ACTIVATE_VENV) && cd automation && molecule test --all .PHONY: molecule-destroy-all molecule-destroy-all: ## Run destroy sequence for all scenarios - $(ACTIVATE_VENV) && molecule destroy --all + $(ACTIVATE_VENV) && cd automation && molecule destroy --all .PHONY: molecule-test-scenario molecule-test-scenario: ## Run molecule test with specific scenario (example: make molecule-test-scenario MOLECULE_SCENARIO="scenario_name") - $(ACTIVATE_VENV) && molecule test --scenario-name $(MOLECULE_SCENARIO) + $(ACTIVATE_VENV) && cd automation && molecule test --scenario-name $(MOLECULE_SCENARIO) .PHONY: molecule-destroy-scenario molecule-destroy-scenario: ## Run molecule destroy with specific scenario (example: make molecule-destroy-scenario MOLECULE_SCENARIO="scenario_name") - $(ACTIVATE_VENV) && molecule destroy --scenario-name $(MOLECULE_SCENARIO) + $(ACTIVATE_VENV) && cd automation && molecule destroy --scenario-name $(MOLECULE_SCENARIO) .PHONY: molecule-converge-scenario molecule-converge-scenario: ## Run molecule converge with specific scenario (example: make molecule-converge-scenario MOLECULE_SCENARIO="scenario_name") - $(ACTIVATE_VENV) && molecule converge --scenario-name $(MOLECULE_SCENARIO) + $(ACTIVATE_VENV) && cd automation && molecule converge --scenario-name $(MOLECULE_SCENARIO) .PHONY: molecule-dependency molecule-dependency: ## Run dependency sequence - $(ACTIVATE_VENV) && molecule dependency + $(ACTIVATE_VENV) && cd automation && molecule dependency .PHONY: molecule-verify molecule-verify: ## Run verify sequence - $(ACTIVATE_VENV) && molecule verify + $(ACTIVATE_VENV) && cd automation && molecule verify .PHONY: molecule-login molecule-login: ## Log in to one instance using custom host IP (example: make molecule-login MOLECULE_HOST="10.172.0.20") - $(ACTIVATE_VENV) && molecule login --host $(MOLECULE_HOST) + $(ACTIVATE_VENV) && cd automation && molecule login --host $(MOLECULE_HOST) .PHONY: molecule-login-scenario molecule-login-scenario: ## Log in to one instance using custom host IP and scenario name (example: make molecule-login-scenario MOLECULE_HOST="10.172.1.20" MOLECULE_SCENARIO="scenario_name") - $(ACTIVATE_VENV) && molecule login --host $(MOLECULE_HOST) --scenario-name $(MOLECULE_SCENARIO) + $(ACTIVATE_VENV) && cd automation && molecule login --host $(MOLECULE_HOST) --scenario-name $(MOLECULE_SCENARIO) diff --git a/.config/make/python.mak b/.config/make/python.mak index f975d325d..03531ea77 100644 --- a/.config/make/python.mak +++ b/.config/make/python.mak @@ -1,6 +1,6 @@ # Python default launcher python_launcher ?= python3.10 -python_requirements_file ?= requirements.txt +python_requirements_file ?= automation/requirements.txt python_requirements_dev_file ?= .config/python/dev/requirements.txt # Activate virtual environment diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 6de6d967c..8cb32e011 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -20,22 +20,21 @@ jobs: run: echo "TERM=xterm" >> $GITHUB_ENV - name: Extract branch or tag name - shell: bash run: | - REF_NAME="" if [[ -n "${GITHUB_HEAD_REF}" ]]; then # This is a PR, use the source branch name - REF_NAME="${GITHUB_HEAD_REF}" + echo "REF_NAME=${GITHUB_HEAD_REF}" >> $GITHUB_ENV else # This is a push, use the branch or tag name from GITHUB_REF - REF_NAME="${GITHUB_REF##*/}" + echo "REF_NAME=${GITHUB_REF##*/}" >> $GITHUB_ENV fi - # If this is the master branch, use 'latest' as the tag, otherwise use the REF_NAME - if [[ "$REF_NAME" == "master" ]]; then + - name: Set TAG + run: | + if [[ "${{ env.REF_NAME }}" == "master" ]]; then echo "TAG=latest" >> $GITHUB_ENV else - echo "TAG=$REF_NAME" >> $GITHUB_ENV + echo "TAG=${{ env.REF_NAME }}" >> $GITHUB_ENV fi - name: Checkout diff --git a/README.md b/README.md index 4e164e5ae..a5585ca04 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,11 @@ [![GitHub license](https://img.shields.io/github/license/vitabaks/postgresql_cluster)](https://github.com/vitabaks/postgresql_cluster/blob/master/LICENSE) ![GitHub stars](https://img.shields.io/github/stars/vitabaks/postgresql_cluster) -### Production-ready PostgreSQL High-Availability Cluster (based on "Patroni" and DCS "etcd" or "consul"). Automating with Ansible. +### Production-ready PostgreSQL High-Availability Cluster (based on Patroni). Automating with Ansible. -The **postgresql_cluster** project is designed to deploy and manage high-availability PostgreSQL clusters in production environments. This solution is tailored for use on dedicated physical servers, virtual machines, and within both on-premises and cloud-based infrastructures. +`postgresql_cluster` automates the deployment and management of highly available PostgreSQL clusters in production environments. This solution is tailored for use on dedicated physical servers, virtual machines, and within both on-premises and cloud-based infrastructures. -This project not only facilitates the creation of new clusters but also offers support for integrating with pre-existing PostgreSQL instances. If you intend to upgrade your conventional PostgreSQL setup to a high-availability configuration, then just set `postgresql_exists=true` in the inventory file. Be aware that initiating cluster mode requires temporarily stopping your existing PostgreSQL service, which will lead to a brief period of database downtime. Please plan this transition accordingly. +You can find a version of this documentation that is searchable and also easier to navigate at [postgresql-cluster.org](http://postgresql-cluster.org) :trophy: **Use the [sponsoring](https://github.com/vitabaks/postgresql_cluster#sponsor-this-project) program to get personalized support, or just to contribute to this project.** @@ -26,27 +26,27 @@ You have three schemes available for deployment: #### 1. PostgreSQL High-Availability only -This is simple scheme without load balancing (used by default). +This is simple scheme without load balancing. ##### Components of high availability: - [**Patroni**](https://github.com/zalando/patroni) is a template for you to create your own customized, high-availability solution using Python and - for maximum accessibility - a distributed configuration store like ZooKeeper, etcd, Consul or Kubernetes. Used for automate the management of PostgreSQL instances and auto failover. -- [**etcd**](https://github.com/etcd-io/etcd) is a distributed reliable key-value store for the most critical data of a distributed system. etcd is written in Go and uses the [Raft](https://raft.github.io/) consensus algorithm to manage a highly-available replicated log. It is used by Patroni to store information about the status of the cluster and PostgreSQL configuration parameters. +- [**etcd**](https://github.com/etcd-io/etcd) is a distributed reliable key-value store for the most critical data of a distributed system. etcd is written in Go and uses the [Raft](https://raft.github.io/) consensus algorithm to manage a highly-available replicated log. It is used by Patroni to store information about the status of the cluster and PostgreSQL configuration parameters. [What is Distributed Consensus?](http://thesecretlivesofdata.com/raft/) -[What is Distributed Consensus?](http://thesecretlivesofdata.com/raft/) +- [**vip-manager**](https://github.com/cybertec-postgresql/vip-manager) (_optional, if the `cluster_vip` variable is specified_) is a service that gets started on all cluster nodes and connects to the DCS. If the local node owns the leader-key, vip-manager starts the configured VIP. In case of a failover, vip-manager removes the VIP on the old leader and the corresponding service on the new leader starts it there. Used to provide a single entry point (VIP) for database access. -To provide a single entry point (VIP) for database access is used "vip-manager". +- [**PgBouncer**](https://pgbouncer.github.io/features.html) (optional, if the `pgbouncer_install` variable is `true`) is a connection pooler for PostgreSQL. -- [**vip-manager**](https://github.com/cybertec-postgresql/vip-manager) (_optional, if the `cluster_vip` variable is specified_) is a service that gets started on all cluster nodes and connects to the DCS. If the local node owns the leader-key, vip-manager starts the configured VIP. In case of a failover, vip-manager removes the VIP on the old leader and the corresponding service on the new leader starts it there. +#### 2. PostgreSQL High-Availability with Load Balancing -- [**PgBouncer**](https://pgbouncer.github.io/features.html) (optional, if the `pgbouncer_install` variable is `true`) is a connection pooler for PostgreSQL. +This scheme enables load distribution for read operations and also allows for scaling out the cluster with read-only replicas. -#### 2. PostgreSQL High-Availability with HAProxy Load Balancing +When deploying to cloud providers such as AWS, GCP, Azure, DigitalOcean, and Hetzner Cloud, a cloud load balancer is automatically created by default to provide a single entry point to the database (controlled by the `cloud_load_balancer` variable). -To use this scheme, specify `with_haproxy_load_balancing: true` in variable file vars/main.yml +For non-cloud environments, such as when deploying on Your Own Machines, the HAProxy load balancer is available for use. To enable it, set `with_haproxy_load_balancing: true` in the vars/main.yml file. -This scheme provides the ability to distribute the load on reading. This also allows us to scale out the cluster (with read-only replicas). +:heavy_exclamation_mark: Note: Your application must have support sending read requests to a custom port 5001, and write requests to port 5000. - port 5000 (read / write) master - port 5001 (read only) all replicas @@ -55,9 +55,7 @@ This scheme provides the ability to distribute the load on reading. This also al - port 5002 (read only) synchronous replica only - port 5003 (read only) asynchronous replicas only -:heavy_exclamation_mark: Note: Your application must have support sending read requests to a custom port 5001, and write requests to port 5000. - -##### Components of load balancing: +##### Components of HAProxy load balancing: - [**HAProxy**](http://www.haproxy.org/) is a free, very fast and reliable solution offering high availability, load balancing, and proxying for TCP and HTTP-based applications. @@ -66,7 +64,7 @@ This scheme provides the ability to distribute the load on reading. This also al - [**Keepalived**](https://github.com/acassen/keepalived) (_optional, if the `cluster_vip` variable is specified_) provides a virtual high-available IP address (VIP) and single entry point for databases access. Implementing VRRP (Virtual Router Redundancy Protocol) for Linux. In our configuration keepalived checks the status of the HAProxy service and in case of a failure delegates the VIP to another server in the cluster. -#### 3. PostgreSQL High-Availability with Consul Service Discovery (DNS) +#### 3. PostgreSQL High-Availability with Consul Service Discovery To use this scheme, specify `dcs_type: consul` in variable file vars/main.yml @@ -189,7 +187,35 @@ To minimize the risk of losing data on autofailover, you can configure settings --- -## Deployment: quick start +## Getting Started + +To run the PostgreSQL Cluster Console, execute the following command: + +``` +docker run -d --name pg-console \ + --publish 80:80 \ + --publish 8080:8080 \ + --env PG_CONSOLE_API_URL=http://localhost:8080/api/v1 \ + --env PG_CONSOLE_AUTHORIZATION_TOKEN=secret_token \ + --volume console_postgres:/var/lib/postgresql \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume /tmp/ansible:/tmp/ansible \ + --restart=unless-stopped \ + vitabaks/postgresql_cluster_console:2.0.0 +``` + +Note: It is recommended to run the console in the same network as your database servers to enable monitoring of the cluster status. In this case, replace `localhost` with your server's IP address in the PG_CONSOLE_API_URL variable. + +**Open the Console UI** + +Go to http://localhost/ and use `secret_token` for authorization. + +Note: If you have set up the console on a different server, replace 'localhost' with the server's address. Use the value of your token if you have redefined it in the PG_CONSOLE_AUTHORIZATION_TOKEN variable. + +
Click here to expand... if you prefer the command line.

+ +#### Command line + 0. [Install Ansible](https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html) on one control node (which could easily be a laptop) ``` @@ -206,7 +232,7 @@ git clone https://github.com/vitabaks/postgresql_cluster.git 2. Go to the playbook directory ``` -cd postgresql_cluster/ +cd postgresql_cluster/automation ``` 3. Edit the inventory file @@ -232,6 +258,9 @@ nano vars/main.yml - `with_haproxy_load_balancing` `'true'` (Type A) or `'false'`/default (Type B) - `dcs_type` # "etcd" (default) or "consul" (Type C) +See the vars/[main.yml](./vars/main.yml), [system.yml](./vars/system.yml) and ([Debian.yml](./vars/Debian.yml) or [RedHat.yml](./vars/RedHat.yml)) files for more details. + + if dcs_type: "consul", please install consul role requirements on the control node: ``` @@ -250,7 +279,7 @@ ansible all -m ping ansible-playbook deploy_pgcluster.yml ``` -### Deploy Cluster with TimescaleDB +#### Deploy Cluster with TimescaleDB To deploy a PostgreSQL High-Availability Cluster with the TimescaleDB extension, you just need to add the `enable_timescale` variable. @@ -261,293 +290,30 @@ ansible-playbook deploy_pgcluster.yml -e "enable_timescale=true" [![asciicast](https://asciinema.org/a/251019.svg)](https://asciinema.org/a/251019?speed=5) ---- - -## Variables -See the vars/[main.yml](./vars/main.yml), [system.yml](./vars/system.yml) and ([Debian.yml](./vars/Debian.yml) or [RedHat.yml](./vars/RedHat.yml)) files for more details. - - -## Cluster Scaling - -After you successfully deployed your PostgreSQL HA cluster, you may need to scale it further. \ -Use the `add_pgnode.yml` playbook for this. - -

Add new postgresql node to existing cluster

- -> This playbook does not scaling the etcd cluster or consul cluster. - -During the run this playbook, the new nodes will be prepared in the same way as when first deployment the cluster. But unlike the initial deployment, all the necessary **configuration files will be copied from the master server**. - -###### Steps to add a new Postgres node: - -1. Add a new node to the inventory file with the variable `new_node=true` -2. Run `add_pgnode.yml` playbook - -In this example, we add a node with the IP address 10.128.64.144 - -``` -[master] -10.128.64.140 hostname=pgnode01 postgresql_exists='true' - -[replica] -10.128.64.142 hostname=pgnode02 postgresql_exists='true' -10.128.64.143 hostname=pgnode03 postgresql_exists='true' -10.128.64.144 hostname=pgnode04 postgresql_exists=false new_node=true -``` - -Run playbook: - -``` -ansible-playbook add_pgnode.yml -``` - -

- -
Add new haproxy balancer node

- -During the run this playbook, the new balancer node will be prepared in the same way as when first deployment the cluster. But unlike the initial deployment, all necessary **configuration files will be copied from the first server specified in the inventory file in the "balancers" group**. - -###### Steps to add a new balancer node: - -Note: Used if the `with_haproxy_load_balancing` variable is set to `true` - -1. Add a new node to the inventory file with the variable `new_node=true` - -2. Run `add_balancer.yml` playbook - - - In this example, we add a balancer node with the IP address 10.128.64.144 - -``` -[balancers] -10.128.64.140 -10.128.64.142 -10.128.64.143 -10.128.64.144 new_node=true -``` - -Run playbook: - -``` -ansible-playbook add_balancer.yml -``` - -

- - -## Restore and Cloning -Create new clusters from your existing backups with [pgBackRest](https://github.com/pgbackrest/pgbackrest) or [WAL-G](https://github.com/wal-g/wal-g) \ -Point-In-Time-Recovery - -
Click here to expand...

- -##### Create cluster with pgBackRest: -1. Edit the `main.yml` variable file -``` -patroni_cluster_bootstrap_method: "pgbackrest" - -patroni_create_replica_methods: - - pgbackrest - - basebackup - -postgresql_restore_command: "pgbackrest --stanza={{ pgbackrest_stanza }} archive-get %f %p" - -pgbackrest_install: true -pgbackrest_stanza: "stanza_name" # specify your --stanza -pgbackrest_repo_type: "posix" # or "s3" -pgbackrest_repo_host: "ip-address" # dedicated repository host (if repo_type: "posix") -pgbackrest_repo_user: "postgres" # if "repo_host" is set -pgbackrest_conf: # see more options https://pgbackrest.org/configuration.html - global: # [global] section - - {option: "xxxxxxx", value: "xxxxxxx"} - ... - stanza: # [stanza_name] section - - {option: "xxxxxxx", value: "xxxxxxx"} - ... - -pgbackrest_patroni_cluster_restore_command: - '/usr/bin/pgbackrest --stanza={{ pgbackrest_stanza }} --type=time "--target=2020-06-01 11:00:00+03" --delta restore' -``` -example for S3 https://github.com/vitabaks/postgresql_cluster/pull/40#issuecomment-647146432 - -2. Run playbook: - -`ansible-playbook deploy_pgcluster.yml` - -##### Create cluster with WAL-G: -1. Edit the `main.yml` variable file -``` -patroni_cluster_bootstrap_method: "wal-g" - -patroni_create_replica_methods: - - wal_g - - basebackup +--- -postgresql_restore_command: "wal-g wal-fetch %f %p" +### How to start from scratch -wal_g_install: true -wal_g_version: "2.0.1" -wal_g_json: # config https://github.com/wal-g/wal-g#configuration - - {option: "xxxxxxx", value: "xxxxxxx"} - - {option: "xxxxxxx", value: "xxxxxxx"} - ... -wal_g_patroni_cluster_bootstrap_command: "wal-g backup-fetch {{ postgresql_data_dir }} LATEST" -``` -2. Run playbook: +If you need to start from the very beginning, you can use the playbook `remove_cluster.yml`. -`ansible-playbook deploy_pgcluster.yml` +Available variables: +- `remove_postgres`: stop the PostgreSQL service and remove data. +- `remove_etcd`: stop the ETCD service and remove data. +- `remove_consul`: stop the Consul service and remove data. +Run the following command to remove specific components: -##### Point-In-Time-Recovery: -You can run automatic restore of your existing patroni cluster \ -for PITR, specify the required parameters in the main.yml variable file and run the playbook with the tag: -``` -ansible-playbook deploy_pgcluster.yml --tags point_in_time_recovery -``` -Recovery steps with pgBackRest: -``` -1. Stop patroni service on the Replica servers (if running); -2. Stop patroni service on the Master server; -3. Remove patroni cluster "xxxxxxx" from DCS (if exist); -4. Run "/usr/bin/pgbackrest --stanza=xxxxxxx --delta restore" on Master; -5. Run "/usr/bin/pgbackrest --stanza=xxxxxxx --delta restore" on Replica (if patroni_create_replica_methods: "pgbackrest"); -6. Waiting for restore from backup (timeout 24 hours); -7. Start PostgreSQL for Recovery (master and replicas); -8. Waiting for PostgreSQL Recovery to complete (WAL apply); -9. Stop PostgreSQL instance (if running); -10. Disable PostgreSQL archive_command (if enabled); -11. Start patroni service on the Master server; -12. Check PostgreSQL is started and accepting connections on Master; -13. Make sure the postgresql users (superuser and replication) are present, and password does not differ from the specified in vars/main.yml; -14. Update postgresql authentication parameter in patroni.yml (if superuser or replication users is changed); -15. Reload patroni service (if patroni.yml is updated); -16. Start patroni service on Replica servers; -17. Check that the patroni is healthy on the replica server (timeout 10 hours); -18. Check postgresql cluster health (finish). +```bash +ansible-playbook remove_cluster.yml -e "remove_postgres=true remove_etcd=true" ``` -**Why disable archive_command?** +This command will delete the specified components, allowing you to start a new installation from scratch. -This is necessary to avoid conflicts in the archived log storage when archiving WALs. When multiple clusters try to send WALs to the same storage. \ -For example, when you make multiple clones of a cluster from one backup. +:warning: **Caution:** be careful when running this command in a production environment. -You can change this parameter using `patronictl edit-config` after restore. \ -Or set `disable_archive_command: false` to not disable archive_command after restore.

- -## Maintenance - -I recommend that you study the following materials for further maintenance of the cluster: - -- [Tutorial: Management of High-Availability PostgreSQL clusters with Patroni](https://pgconf.ru/en/2018/108567) -- [Patroni documentation](https://patroni.readthedocs.io/en/latest/) -- [etcd operations guide](https://etcd.io/docs/v3.5/op-guide/) - -### Changing PostgreSQL configuration parameters - -To change the PostgreSQL configuration in a cluster using automation: - -1. Update the `postgresql_parameters` variable with the desired parameter changes. - - Note: Optionally, set `pending_restart: true` to automatically restart PostgreSQL if a parameter change requires it. -3. Execute the `config_pgcluster.yml` playbook to apply the changes. - -#### Using Git for cluster configuration management (IaC/GitOps) - -Infrastructure as Code (IaC) is the managing and provisioning of infrastructure through code instead of through manual processes. \ -GitOps automates infrastructure updates using a Git workflow with continuous integration (CI) and continuous delivery (CI/CD). When new code is merged, the CI/CD pipeline enacts the change in the environment. Any configuration drift, such as manual changes or errors, is overwritten by GitOps automation so the environment converges on the desired state defined in Git. - -Once the cluster is deployed, you can use the `config_pgcluster.yml` playbook to integrate with Git to manage cluster configurations. \ -For example, GitHub Action ([link](https://github.com/marketplace/actions/run-ansible-playbook)), GitLab CI/CD ([link](https://medium.com/geekculture/how-to-run-an-ansible-playbook-using-gitlab-ci-cd-2135f76d7f1e)) - -Details about IaC and GitOps: - -- [What is GitOps](https://about.gitlab.com/topics/gitops/)? -- [What is Infrastructure as Code (IaC)](https://www.redhat.com/en/topics/automation/what-is-infrastructure-as-code-iac)? - - -### Update the PostgreSQL HA Cluster - -Use the `update_pgcluster.yml` playbook for update the PostgreSQL HA Cluster to a new minor version (for example 15.1->15.2, and etc). - -
Update PostgreSQL - -``` -ansible-playbook update_pgcluster.yml -e target=postgres -``` - -
- - -
Update Patroni - -``` -ansible-playbook update_pgcluster.yml -e target=patroni -``` - -
- -
Update all system - -includes PostgreSQL and Patroni - -``` -ansible-playbook update_pgcluster.yml -e target=system -``` - -
- -More details [here](roles/update/README.md) - -### PostgreSQL major upgrade - -Use the `pg_upgrade.yml` playbook to upgrade the PostgreSQL to a new major version (for example 14->15, and etc). - -
Upgrade PostgreSQL - -``` -ansible-playbook pg_upgrade.yml -e "pg_old_version=14 pg_new_version=15" -``` - -
- -More details [here](roles/upgrade/README.md) - -## Disaster Recovery - -A high availability cluster provides an automatic failover mechanism, and does not cover all disaster recovery scenarios. -You must take care of backing up your data yourself. -##### etcd -> Patroni nodes are dumping the state of the DCS options to disk upon for every change of the configuration into the file patroni.dynamic.json located in the Postgres data directory. The master (patroni leader) is allowed to restore these options from the on-disk dump if these are completely absent from the DCS or if they are invalid. - -However, I recommend that you read the disaster recovery guide for the etcd cluster: -- [etcd disaster recovery](https://etcd.io/docs/v3.3.12/op-guide/recovery) - -##### PostgreSQL (databases) -I can recommend the following backup and restore tools: -* [pgbackrest](https://github.com/pgbackrest/pgbackrest) -* [pg_probackup](https://github.com/postgrespro/pg_probackup) -* [wal-g](https://github.com/wal-g/wal-g) - -Do not forget to validate your backups (for example [pgbackrest auto](https://github.com/vitabaks/pgbackrest_auto)). - -## How to start from scratch -Should you need to start from very beginning, use the playbook `remove_cluster.yml`. - -To prevent the script to be used by accident in a production environment, edit `remove_cluster.yml` and remove the *safety pin*. Change these variables accordingly: - -- remove_postgres: true -- remove_etcd: true (or remove_consul) - -Run the script and all the data are gone. - -`ansible-playbook remove_cluster.yml` - -A new installation can now be made from scratch. - -:heavy_exclamation_mark: Be careful not to copy this script without the *safety pin* to the production environment. - --- ## Sponsor this project diff --git a/.dockerignore b/automation/.dockerignore similarity index 100% rename from .dockerignore rename to automation/.dockerignore diff --git a/Dockerfile b/automation/Dockerfile similarity index 84% rename from Dockerfile rename to automation/Dockerfile index 19a699223..1552d0ecf 100644 --- a/Dockerfile +++ b/automation/Dockerfile @@ -7,7 +7,7 @@ USER root SHELL ["/bin/bash", "-o", "pipefail", "-c"] # Copy postgresql_cluster repository -COPY . /postgresql_cluster +COPY automation /postgresql_cluster/automation # Install required packages, Python dependencies, Ansible requirements, and perform cleanup RUN apt-get clean && rm -rf /var/lib/apt/lists/partial \ @@ -16,11 +16,11 @@ RUN apt-get clean && rm -rf /var/lib/apt/lists/partial \ ca-certificates gnupg git python3 python3-dev python3-pip keychain ssh-client sshpass\ gcc g++ cmake make libssl-dev curl apt-transport-https lsb-release gnupg \ && pip3 install --break-system-packages --no-cache-dir -r \ - /postgresql_cluster/requirements.txt \ + /postgresql_cluster/automation/requirements.txt \ && ansible-galaxy install --force -r \ - /postgresql_cluster/requirements.yml \ + /postgresql_cluster/automation/requirements.yml \ && ansible-galaxy install --force -r \ - /postgresql_cluster/roles/consul/requirements.yml \ + /postgresql_cluster/automation/roles/consul/requirements.yml \ && ansible-galaxy collection list \ && pip3 install --break-system-packages --no-cache-dir -r \ /root/.ansible/collections/ansible_collections/azure/azcollection/requirements.txt \ @@ -30,12 +30,12 @@ RUN apt-get clean && rm -rf /var/lib/apt/lists/partial \ && apt-get autoremove -y --purge gnupg git python3-dev gcc g++ cmake make libssl-dev \ && apt-get clean -y autoclean \ && rm -rf /var/lib/apt/lists/* /tmp/* \ - && chmod +x /postgresql_cluster/entrypoint.sh + && chmod +x /postgresql_cluster/automation/entrypoint.sh # Set environment variable for Ansible collections paths ENV ANSIBLE_COLLECTIONS_PATH=/root/.ansible/collections/ansible_collections:/usr/local/lib/python3.11/dist-packages/ansible_collections ENV USER=root -WORKDIR /postgresql_cluster +WORKDIR /postgresql_cluster/automation ENTRYPOINT ["./entrypoint.sh"] diff --git a/add_balancer.yml b/automation/add_balancer.yml similarity index 100% rename from add_balancer.yml rename to automation/add_balancer.yml diff --git a/add_pgnode.yml b/automation/add_pgnode.yml similarity index 100% rename from add_pgnode.yml rename to automation/add_pgnode.yml diff --git a/ansible.cfg b/automation/ansible.cfg similarity index 100% rename from ansible.cfg rename to automation/ansible.cfg diff --git a/balancers.yml b/automation/balancers.yml similarity index 100% rename from balancers.yml rename to automation/balancers.yml diff --git a/config_pgcluster.yml b/automation/config_pgcluster.yml similarity index 100% rename from config_pgcluster.yml rename to automation/config_pgcluster.yml diff --git a/consul.yml b/automation/consul.yml similarity index 100% rename from consul.yml rename to automation/consul.yml diff --git a/deploy_pgcluster.yml b/automation/deploy_pgcluster.yml similarity index 100% rename from deploy_pgcluster.yml rename to automation/deploy_pgcluster.yml diff --git a/entrypoint.sh b/automation/entrypoint.sh similarity index 100% rename from entrypoint.sh rename to automation/entrypoint.sh diff --git a/etcd_cluster.yml b/automation/etcd_cluster.yml similarity index 100% rename from etcd_cluster.yml rename to automation/etcd_cluster.yml diff --git a/files/requirements.txt b/automation/files/requirements.txt similarity index 100% rename from files/requirements.txt rename to automation/files/requirements.txt diff --git a/group_vars/all b/automation/group_vars/all similarity index 100% rename from group_vars/all rename to automation/group_vars/all diff --git a/group_vars/master b/automation/group_vars/master similarity index 100% rename from group_vars/master rename to automation/group_vars/master diff --git a/group_vars/replica b/automation/group_vars/replica similarity index 100% rename from group_vars/replica rename to automation/group_vars/replica diff --git a/inventory b/automation/inventory similarity index 100% rename from inventory rename to automation/inventory diff --git a/molecule/default/cleanup.yml b/automation/molecule/default/cleanup.yml similarity index 100% rename from molecule/default/cleanup.yml rename to automation/molecule/default/cleanup.yml diff --git a/molecule/default/converge.yml b/automation/molecule/default/converge.yml similarity index 100% rename from molecule/default/converge.yml rename to automation/molecule/default/converge.yml diff --git a/molecule/default/molecule.yml b/automation/molecule/default/molecule.yml similarity index 100% rename from molecule/default/molecule.yml rename to automation/molecule/default/molecule.yml diff --git a/molecule/default/prepare.yml b/automation/molecule/default/prepare.yml similarity index 100% rename from molecule/default/prepare.yml rename to automation/molecule/default/prepare.yml diff --git a/molecule/default/verify.yml b/automation/molecule/default/verify.yml similarity index 100% rename from molecule/default/verify.yml rename to automation/molecule/default/verify.yml diff --git a/molecule/pg_upgrade/converge.yml b/automation/molecule/pg_upgrade/converge.yml similarity index 100% rename from molecule/pg_upgrade/converge.yml rename to automation/molecule/pg_upgrade/converge.yml diff --git a/molecule/pg_upgrade/molecule.yml b/automation/molecule/pg_upgrade/molecule.yml similarity index 100% rename from molecule/pg_upgrade/molecule.yml rename to automation/molecule/pg_upgrade/molecule.yml diff --git a/molecule/pg_upgrade/prepare.yml b/automation/molecule/pg_upgrade/prepare.yml similarity index 100% rename from molecule/pg_upgrade/prepare.yml rename to automation/molecule/pg_upgrade/prepare.yml diff --git a/molecule/postgrespro/converge.yml b/automation/molecule/postgrespro/converge.yml similarity index 100% rename from molecule/postgrespro/converge.yml rename to automation/molecule/postgrespro/converge.yml diff --git a/molecule/postgrespro/molecule.yml b/automation/molecule/postgrespro/molecule.yml similarity index 100% rename from molecule/postgrespro/molecule.yml rename to automation/molecule/postgrespro/molecule.yml diff --git a/molecule/postgrespro/prepare.yml b/automation/molecule/postgrespro/prepare.yml similarity index 100% rename from molecule/postgrespro/prepare.yml rename to automation/molecule/postgrespro/prepare.yml diff --git a/molecule/postgrespro/vars/postgrespro_vars.yml b/automation/molecule/postgrespro/vars/postgrespro_vars.yml similarity index 100% rename from molecule/postgrespro/vars/postgrespro_vars.yml rename to automation/molecule/postgrespro/vars/postgrespro_vars.yml diff --git a/molecule/tests/etcd/etcd.yml b/automation/molecule/tests/etcd/etcd.yml similarity index 100% rename from molecule/tests/etcd/etcd.yml rename to automation/molecule/tests/etcd/etcd.yml diff --git a/molecule/tests/patroni/patroni.yml b/automation/molecule/tests/patroni/patroni.yml similarity index 100% rename from molecule/tests/patroni/patroni.yml rename to automation/molecule/tests/patroni/patroni.yml diff --git a/molecule/tests/postgres/postgres.yml b/automation/molecule/tests/postgres/postgres.yml similarity index 100% rename from molecule/tests/postgres/postgres.yml rename to automation/molecule/tests/postgres/postgres.yml diff --git a/molecule/tests/postgres/replication.yml b/automation/molecule/tests/postgres/replication.yml similarity index 100% rename from molecule/tests/postgres/replication.yml rename to automation/molecule/tests/postgres/replication.yml diff --git a/molecule/tests/roles/confd/main.yml b/automation/molecule/tests/roles/confd/main.yml similarity index 100% rename from molecule/tests/roles/confd/main.yml rename to automation/molecule/tests/roles/confd/main.yml diff --git a/molecule/tests/roles/confd/variables/haproxy.tmpl.yml b/automation/molecule/tests/roles/confd/variables/haproxy.tmpl.yml similarity index 100% rename from molecule/tests/roles/confd/variables/haproxy.tmpl.yml rename to automation/molecule/tests/roles/confd/variables/haproxy.tmpl.yml diff --git a/molecule/tests/roles/deploy-finish/main.yml b/automation/molecule/tests/roles/deploy-finish/main.yml similarity index 100% rename from molecule/tests/roles/deploy-finish/main.yml rename to automation/molecule/tests/roles/deploy-finish/main.yml diff --git a/molecule/tests/roles/deploy-finish/variables/haproxy_nodes.yml b/automation/molecule/tests/roles/deploy-finish/variables/haproxy_nodes.yml similarity index 100% rename from molecule/tests/roles/deploy-finish/variables/haproxy_nodes.yml rename to automation/molecule/tests/roles/deploy-finish/variables/haproxy_nodes.yml diff --git a/molecule/tests/roles/haproxy/main.yml b/automation/molecule/tests/roles/haproxy/main.yml similarity index 100% rename from molecule/tests/roles/haproxy/main.yml rename to automation/molecule/tests/roles/haproxy/main.yml diff --git a/molecule/tests/roles/haproxy/variables/haproxy.cfg.yml b/automation/molecule/tests/roles/haproxy/variables/haproxy.cfg.yml similarity index 100% rename from molecule/tests/roles/haproxy/variables/haproxy.cfg.yml rename to automation/molecule/tests/roles/haproxy/variables/haproxy.cfg.yml diff --git a/molecule/tests/roles/patroni/main.yml b/automation/molecule/tests/roles/patroni/main.yml similarity index 100% rename from molecule/tests/roles/patroni/main.yml rename to automation/molecule/tests/roles/patroni/main.yml diff --git a/molecule/tests/roles/patroni/variables/custom_wal_dir.yml b/automation/molecule/tests/roles/patroni/variables/custom_wal_dir.yml similarity index 100% rename from molecule/tests/roles/patroni/variables/custom_wal_dir.yml rename to automation/molecule/tests/roles/patroni/variables/custom_wal_dir.yml diff --git a/molecule/tests/roles/pre-checks/main.yml b/automation/molecule/tests/roles/pre-checks/main.yml similarity index 100% rename from molecule/tests/roles/pre-checks/main.yml rename to automation/molecule/tests/roles/pre-checks/main.yml diff --git a/molecule/tests/roles/pre-checks/variables/pgbouncer.yml b/automation/molecule/tests/roles/pre-checks/variables/pgbouncer.yml similarity index 100% rename from molecule/tests/roles/pre-checks/variables/pgbouncer.yml rename to automation/molecule/tests/roles/pre-checks/variables/pgbouncer.yml diff --git a/molecule/tests/roles/pre-checks/variables/timescaledb.yml b/automation/molecule/tests/roles/pre-checks/variables/timescaledb.yml similarity index 100% rename from molecule/tests/roles/pre-checks/variables/timescaledb.yml rename to automation/molecule/tests/roles/pre-checks/variables/timescaledb.yml diff --git a/molecule/tests/roles/swap/conditions/create.yml b/automation/molecule/tests/roles/swap/conditions/create.yml similarity index 100% rename from molecule/tests/roles/swap/conditions/create.yml rename to automation/molecule/tests/roles/swap/conditions/create.yml diff --git a/molecule/tests/roles/swap/conditions/delete.yml b/automation/molecule/tests/roles/swap/conditions/delete.yml similarity index 100% rename from molecule/tests/roles/swap/conditions/delete.yml rename to automation/molecule/tests/roles/swap/conditions/delete.yml diff --git a/molecule/tests/roles/swap/main.yml b/automation/molecule/tests/roles/swap/main.yml similarity index 100% rename from molecule/tests/roles/swap/main.yml rename to automation/molecule/tests/roles/swap/main.yml diff --git a/molecule/tests/variables/asserts/apt_repository.yml b/automation/molecule/tests/variables/asserts/apt_repository.yml similarity index 100% rename from molecule/tests/variables/asserts/apt_repository.yml rename to automation/molecule/tests/variables/asserts/apt_repository.yml diff --git a/molecule/tests/variables/asserts/baseurl.yml b/automation/molecule/tests/variables/asserts/baseurl.yml similarity index 100% rename from molecule/tests/variables/asserts/baseurl.yml rename to automation/molecule/tests/variables/asserts/baseurl.yml diff --git a/molecule/tests/variables/asserts/pg_probackup.yml b/automation/molecule/tests/variables/asserts/pg_probackup.yml similarity index 100% rename from molecule/tests/variables/asserts/pg_probackup.yml rename to automation/molecule/tests/variables/asserts/pg_probackup.yml diff --git a/molecule/tests/variables/asserts/system_info.yml b/automation/molecule/tests/variables/asserts/system_info.yml similarity index 100% rename from molecule/tests/variables/asserts/system_info.yml rename to automation/molecule/tests/variables/asserts/system_info.yml diff --git a/molecule/tests/variables/asserts/vip_manager_package_repo.yml b/automation/molecule/tests/variables/asserts/vip_manager_package_repo.yml similarity index 100% rename from molecule/tests/variables/asserts/vip_manager_package_repo.yml rename to automation/molecule/tests/variables/asserts/vip_manager_package_repo.yml diff --git a/molecule/tests/variables/asserts/wal_g_cron_jobs.yml b/automation/molecule/tests/variables/asserts/wal_g_cron_jobs.yml similarity index 100% rename from molecule/tests/variables/asserts/wal_g_cron_jobs.yml rename to automation/molecule/tests/variables/asserts/wal_g_cron_jobs.yml diff --git a/molecule/tests/variables/main.yml b/automation/molecule/tests/variables/main.yml similarity index 100% rename from molecule/tests/variables/main.yml rename to automation/molecule/tests/variables/main.yml diff --git a/pg_upgrade.yml b/automation/pg_upgrade.yml similarity index 100% rename from pg_upgrade.yml rename to automation/pg_upgrade.yml diff --git a/pg_upgrade_rollback.yml b/automation/pg_upgrade_rollback.yml similarity index 100% rename from pg_upgrade_rollback.yml rename to automation/pg_upgrade_rollback.yml diff --git a/plugins/callback/json_log.py b/automation/plugins/callback/json_log.py similarity index 100% rename from plugins/callback/json_log.py rename to automation/plugins/callback/json_log.py diff --git a/remove_cluster.yml b/automation/remove_cluster.yml similarity index 100% rename from remove_cluster.yml rename to automation/remove_cluster.yml diff --git a/requirements.txt b/automation/requirements.txt similarity index 100% rename from requirements.txt rename to automation/requirements.txt diff --git a/requirements.yml b/automation/requirements.yml similarity index 100% rename from requirements.yml rename to automation/requirements.yml diff --git a/roles/add-repository/tasks/extensions.yml b/automation/roles/add-repository/tasks/extensions.yml similarity index 100% rename from roles/add-repository/tasks/extensions.yml rename to automation/roles/add-repository/tasks/extensions.yml diff --git a/roles/add-repository/tasks/main.yml b/automation/roles/add-repository/tasks/main.yml similarity index 100% rename from roles/add-repository/tasks/main.yml rename to automation/roles/add-repository/tasks/main.yml diff --git a/roles/ansible-role-firewall/.gitignore b/automation/roles/ansible-role-firewall/.gitignore similarity index 100% rename from roles/ansible-role-firewall/.gitignore rename to automation/roles/ansible-role-firewall/.gitignore diff --git a/roles/ansible-role-firewall/.travis.yml b/automation/roles/ansible-role-firewall/.travis.yml similarity index 100% rename from roles/ansible-role-firewall/.travis.yml rename to automation/roles/ansible-role-firewall/.travis.yml diff --git a/roles/ansible-role-firewall/.yamllint b/automation/roles/ansible-role-firewall/.yamllint similarity index 100% rename from roles/ansible-role-firewall/.yamllint rename to automation/roles/ansible-role-firewall/.yamllint diff --git a/roles/ansible-role-firewall/LICENSE b/automation/roles/ansible-role-firewall/LICENSE similarity index 100% rename from roles/ansible-role-firewall/LICENSE rename to automation/roles/ansible-role-firewall/LICENSE diff --git a/roles/ansible-role-firewall/README.md b/automation/roles/ansible-role-firewall/README.md similarity index 100% rename from roles/ansible-role-firewall/README.md rename to automation/roles/ansible-role-firewall/README.md diff --git a/roles/ansible-role-firewall/defaults/main.yml b/automation/roles/ansible-role-firewall/defaults/main.yml similarity index 100% rename from roles/ansible-role-firewall/defaults/main.yml rename to automation/roles/ansible-role-firewall/defaults/main.yml diff --git a/roles/ansible-role-firewall/handlers/main.yml b/automation/roles/ansible-role-firewall/handlers/main.yml similarity index 100% rename from roles/ansible-role-firewall/handlers/main.yml rename to automation/roles/ansible-role-firewall/handlers/main.yml diff --git a/roles/ansible-role-firewall/tasks/disable-other-firewalls.yml b/automation/roles/ansible-role-firewall/tasks/disable-other-firewalls.yml similarity index 100% rename from roles/ansible-role-firewall/tasks/disable-other-firewalls.yml rename to automation/roles/ansible-role-firewall/tasks/disable-other-firewalls.yml diff --git a/roles/ansible-role-firewall/tasks/main.yml b/automation/roles/ansible-role-firewall/tasks/main.yml similarity index 100% rename from roles/ansible-role-firewall/tasks/main.yml rename to automation/roles/ansible-role-firewall/tasks/main.yml diff --git a/roles/ansible-role-firewall/templates/firewall.bash.j2 b/automation/roles/ansible-role-firewall/templates/firewall.bash.j2 similarity index 100% rename from roles/ansible-role-firewall/templates/firewall.bash.j2 rename to automation/roles/ansible-role-firewall/templates/firewall.bash.j2 diff --git a/roles/ansible-role-firewall/templates/firewall.init.j2 b/automation/roles/ansible-role-firewall/templates/firewall.init.j2 similarity index 100% rename from roles/ansible-role-firewall/templates/firewall.init.j2 rename to automation/roles/ansible-role-firewall/templates/firewall.init.j2 diff --git a/roles/ansible-role-firewall/templates/firewall.unit.j2 b/automation/roles/ansible-role-firewall/templates/firewall.unit.j2 similarity index 100% rename from roles/ansible-role-firewall/templates/firewall.unit.j2 rename to automation/roles/ansible-role-firewall/templates/firewall.unit.j2 diff --git a/roles/authorized-keys/defaults/main.yml b/automation/roles/authorized-keys/defaults/main.yml similarity index 100% rename from roles/authorized-keys/defaults/main.yml rename to automation/roles/authorized-keys/defaults/main.yml diff --git a/roles/authorized-keys/tasks/main.yml b/automation/roles/authorized-keys/tasks/main.yml similarity index 100% rename from roles/authorized-keys/tasks/main.yml rename to automation/roles/authorized-keys/tasks/main.yml diff --git a/roles/cloud-resources/defaults/main.yml b/automation/roles/cloud-resources/defaults/main.yml similarity index 100% rename from roles/cloud-resources/defaults/main.yml rename to automation/roles/cloud-resources/defaults/main.yml diff --git a/roles/cloud-resources/tasks/aws.yml b/automation/roles/cloud-resources/tasks/aws.yml similarity index 100% rename from roles/cloud-resources/tasks/aws.yml rename to automation/roles/cloud-resources/tasks/aws.yml diff --git a/roles/cloud-resources/tasks/azure.yml b/automation/roles/cloud-resources/tasks/azure.yml similarity index 100% rename from roles/cloud-resources/tasks/azure.yml rename to automation/roles/cloud-resources/tasks/azure.yml diff --git a/roles/cloud-resources/tasks/digitalocean.yml b/automation/roles/cloud-resources/tasks/digitalocean.yml similarity index 100% rename from roles/cloud-resources/tasks/digitalocean.yml rename to automation/roles/cloud-resources/tasks/digitalocean.yml diff --git a/roles/cloud-resources/tasks/gcp.yml b/automation/roles/cloud-resources/tasks/gcp.yml similarity index 100% rename from roles/cloud-resources/tasks/gcp.yml rename to automation/roles/cloud-resources/tasks/gcp.yml diff --git a/roles/cloud-resources/tasks/hetzner.yml b/automation/roles/cloud-resources/tasks/hetzner.yml similarity index 100% rename from roles/cloud-resources/tasks/hetzner.yml rename to automation/roles/cloud-resources/tasks/hetzner.yml diff --git a/roles/cloud-resources/tasks/inventory.yml b/automation/roles/cloud-resources/tasks/inventory.yml similarity index 100% rename from roles/cloud-resources/tasks/inventory.yml rename to automation/roles/cloud-resources/tasks/inventory.yml diff --git a/roles/cloud-resources/tasks/main.yml b/automation/roles/cloud-resources/tasks/main.yml similarity index 100% rename from roles/cloud-resources/tasks/main.yml rename to automation/roles/cloud-resources/tasks/main.yml diff --git a/roles/confd/handlers/main.yml b/automation/roles/confd/handlers/main.yml similarity index 100% rename from roles/confd/handlers/main.yml rename to automation/roles/confd/handlers/main.yml diff --git a/roles/confd/tasks/main.yml b/automation/roles/confd/tasks/main.yml similarity index 100% rename from roles/confd/tasks/main.yml rename to automation/roles/confd/tasks/main.yml diff --git a/roles/confd/templates/confd.service.j2 b/automation/roles/confd/templates/confd.service.j2 similarity index 100% rename from roles/confd/templates/confd.service.j2 rename to automation/roles/confd/templates/confd.service.j2 diff --git a/roles/confd/templates/confd.toml.j2 b/automation/roles/confd/templates/confd.toml.j2 similarity index 100% rename from roles/confd/templates/confd.toml.j2 rename to automation/roles/confd/templates/confd.toml.j2 diff --git a/roles/confd/templates/haproxy.tmpl.j2 b/automation/roles/confd/templates/haproxy.tmpl.j2 similarity index 100% rename from roles/confd/templates/haproxy.tmpl.j2 rename to automation/roles/confd/templates/haproxy.tmpl.j2 diff --git a/roles/confd/templates/haproxy.toml.j2 b/automation/roles/confd/templates/haproxy.toml.j2 similarity index 100% rename from roles/confd/templates/haproxy.toml.j2 rename to automation/roles/confd/templates/haproxy.toml.j2 diff --git a/roles/consul/CHANGELOG.md b/automation/roles/consul/CHANGELOG.md similarity index 100% rename from roles/consul/CHANGELOG.md rename to automation/roles/consul/CHANGELOG.md diff --git a/roles/consul/CONTRIBUTING.md b/automation/roles/consul/CONTRIBUTING.md similarity index 100% rename from roles/consul/CONTRIBUTING.md rename to automation/roles/consul/CONTRIBUTING.md diff --git a/roles/consul/CONTRIBUTORS.md b/automation/roles/consul/CONTRIBUTORS.md similarity index 100% rename from roles/consul/CONTRIBUTORS.md rename to automation/roles/consul/CONTRIBUTORS.md diff --git a/roles/consul/LICENSE.txt b/automation/roles/consul/LICENSE.txt similarity index 100% rename from roles/consul/LICENSE.txt rename to automation/roles/consul/LICENSE.txt diff --git a/roles/consul/README.md b/automation/roles/consul/README.md similarity index 100% rename from roles/consul/README.md rename to automation/roles/consul/README.md diff --git a/roles/consul/defaults/main.yml b/automation/roles/consul/defaults/main.yml similarity index 100% rename from roles/consul/defaults/main.yml rename to automation/roles/consul/defaults/main.yml diff --git a/roles/consul/files/README.md b/automation/roles/consul/files/README.md similarity index 100% rename from roles/consul/files/README.md rename to automation/roles/consul/files/README.md diff --git a/roles/consul/handlers/main.yml b/automation/roles/consul/handlers/main.yml similarity index 100% rename from roles/consul/handlers/main.yml rename to automation/roles/consul/handlers/main.yml diff --git a/roles/consul/handlers/reload_consul_conf.yml b/automation/roles/consul/handlers/reload_consul_conf.yml similarity index 100% rename from roles/consul/handlers/reload_consul_conf.yml rename to automation/roles/consul/handlers/reload_consul_conf.yml diff --git a/roles/consul/handlers/restart_consul.yml b/automation/roles/consul/handlers/restart_consul.yml similarity index 100% rename from roles/consul/handlers/restart_consul.yml rename to automation/roles/consul/handlers/restart_consul.yml diff --git a/roles/consul/handlers/restart_consul_mac.yml b/automation/roles/consul/handlers/restart_consul_mac.yml similarity index 100% rename from roles/consul/handlers/restart_consul_mac.yml rename to automation/roles/consul/handlers/restart_consul_mac.yml diff --git a/roles/consul/handlers/restart_rsyslog.yml b/automation/roles/consul/handlers/restart_rsyslog.yml similarity index 100% rename from roles/consul/handlers/restart_rsyslog.yml rename to automation/roles/consul/handlers/restart_rsyslog.yml diff --git a/roles/consul/handlers/restart_syslogng.yml b/automation/roles/consul/handlers/restart_syslogng.yml similarity index 100% rename from roles/consul/handlers/restart_syslogng.yml rename to automation/roles/consul/handlers/restart_syslogng.yml diff --git a/roles/consul/handlers/start_consul.yml b/automation/roles/consul/handlers/start_consul.yml similarity index 100% rename from roles/consul/handlers/start_consul.yml rename to automation/roles/consul/handlers/start_consul.yml diff --git a/roles/consul/handlers/start_consul_mac.yml b/automation/roles/consul/handlers/start_consul_mac.yml similarity index 100% rename from roles/consul/handlers/start_consul_mac.yml rename to automation/roles/consul/handlers/start_consul_mac.yml diff --git a/roles/consul/handlers/start_snapshot.yml b/automation/roles/consul/handlers/start_snapshot.yml similarity index 100% rename from roles/consul/handlers/start_snapshot.yml rename to automation/roles/consul/handlers/start_snapshot.yml diff --git a/roles/consul/handlers/stop_consul_mac.yml b/automation/roles/consul/handlers/stop_consul_mac.yml similarity index 100% rename from roles/consul/handlers/stop_consul_mac.yml rename to automation/roles/consul/handlers/stop_consul_mac.yml diff --git a/roles/consul/requirements.txt b/automation/roles/consul/requirements.txt similarity index 100% rename from roles/consul/requirements.txt rename to automation/roles/consul/requirements.txt diff --git a/roles/consul/requirements.yml b/automation/roles/consul/requirements.yml similarity index 100% rename from roles/consul/requirements.yml rename to automation/roles/consul/requirements.yml diff --git a/roles/consul/tasks/acl.yml b/automation/roles/consul/tasks/acl.yml similarity index 100% rename from roles/consul/tasks/acl.yml rename to automation/roles/consul/tasks/acl.yml diff --git a/roles/consul/tasks/asserts.yml b/automation/roles/consul/tasks/asserts.yml similarity index 100% rename from roles/consul/tasks/asserts.yml rename to automation/roles/consul/tasks/asserts.yml diff --git a/roles/consul/tasks/config.yml b/automation/roles/consul/tasks/config.yml similarity index 100% rename from roles/consul/tasks/config.yml rename to automation/roles/consul/tasks/config.yml diff --git a/roles/consul/tasks/config_windows.yml b/automation/roles/consul/tasks/config_windows.yml similarity index 100% rename from roles/consul/tasks/config_windows.yml rename to automation/roles/consul/tasks/config_windows.yml diff --git a/roles/consul/tasks/dirs.yml b/automation/roles/consul/tasks/dirs.yml similarity index 100% rename from roles/consul/tasks/dirs.yml rename to automation/roles/consul/tasks/dirs.yml diff --git a/roles/consul/tasks/dnsmasq.yml b/automation/roles/consul/tasks/dnsmasq.yml similarity index 100% rename from roles/consul/tasks/dnsmasq.yml rename to automation/roles/consul/tasks/dnsmasq.yml diff --git a/roles/consul/tasks/encrypt_gossip.yml b/automation/roles/consul/tasks/encrypt_gossip.yml similarity index 100% rename from roles/consul/tasks/encrypt_gossip.yml rename to automation/roles/consul/tasks/encrypt_gossip.yml diff --git a/roles/consul/tasks/install.yml b/automation/roles/consul/tasks/install.yml similarity index 100% rename from roles/consul/tasks/install.yml rename to automation/roles/consul/tasks/install.yml diff --git a/roles/consul/tasks/install_linux_repo.yml b/automation/roles/consul/tasks/install_linux_repo.yml similarity index 100% rename from roles/consul/tasks/install_linux_repo.yml rename to automation/roles/consul/tasks/install_linux_repo.yml diff --git a/roles/consul/tasks/install_remote.yml b/automation/roles/consul/tasks/install_remote.yml similarity index 100% rename from roles/consul/tasks/install_remote.yml rename to automation/roles/consul/tasks/install_remote.yml diff --git a/roles/consul/tasks/install_windows.yml b/automation/roles/consul/tasks/install_windows.yml similarity index 100% rename from roles/consul/tasks/install_windows.yml rename to automation/roles/consul/tasks/install_windows.yml diff --git a/roles/consul/tasks/iptables.yml b/automation/roles/consul/tasks/iptables.yml similarity index 100% rename from roles/consul/tasks/iptables.yml rename to automation/roles/consul/tasks/iptables.yml diff --git a/roles/consul/tasks/main.yml b/automation/roles/consul/tasks/main.yml similarity index 100% rename from roles/consul/tasks/main.yml rename to automation/roles/consul/tasks/main.yml diff --git a/roles/consul/tasks/nix.yml b/automation/roles/consul/tasks/nix.yml similarity index 100% rename from roles/consul/tasks/nix.yml rename to automation/roles/consul/tasks/nix.yml diff --git a/roles/consul/tasks/services.yml b/automation/roles/consul/tasks/services.yml similarity index 100% rename from roles/consul/tasks/services.yml rename to automation/roles/consul/tasks/services.yml diff --git a/roles/consul/tasks/snapshot.yml b/automation/roles/consul/tasks/snapshot.yml similarity index 100% rename from roles/consul/tasks/snapshot.yml rename to automation/roles/consul/tasks/snapshot.yml diff --git a/roles/consul/tasks/syslog.yml b/automation/roles/consul/tasks/syslog.yml similarity index 100% rename from roles/consul/tasks/syslog.yml rename to automation/roles/consul/tasks/syslog.yml diff --git a/roles/consul/tasks/tls.yml b/automation/roles/consul/tasks/tls.yml similarity index 100% rename from roles/consul/tasks/tls.yml rename to automation/roles/consul/tasks/tls.yml diff --git a/roles/consul/tasks/user_group.yml b/automation/roles/consul/tasks/user_group.yml similarity index 100% rename from roles/consul/tasks/user_group.yml rename to automation/roles/consul/tasks/user_group.yml diff --git a/roles/consul/tasks/windows.yml b/automation/roles/consul/tasks/windows.yml similarity index 100% rename from roles/consul/tasks/windows.yml rename to automation/roles/consul/tasks/windows.yml diff --git a/roles/consul/templates/config.json.j2 b/automation/roles/consul/templates/config.json.j2 similarity index 100% rename from roles/consul/templates/config.json.j2 rename to automation/roles/consul/templates/config.json.j2 diff --git a/roles/consul/templates/configd_50acl_policy.hcl.j2 b/automation/roles/consul/templates/configd_50acl_policy.hcl.j2 similarity index 100% rename from roles/consul/templates/configd_50acl_policy.hcl.j2 rename to automation/roles/consul/templates/configd_50acl_policy.hcl.j2 diff --git a/roles/consul/templates/configd_50custom.json.j2 b/automation/roles/consul/templates/configd_50custom.json.j2 similarity index 100% rename from roles/consul/templates/configd_50custom.json.j2 rename to automation/roles/consul/templates/configd_50custom.json.j2 diff --git a/roles/consul/templates/consul_bsdinit.j2 b/automation/roles/consul/templates/consul_bsdinit.j2 similarity index 100% rename from roles/consul/templates/consul_bsdinit.j2 rename to automation/roles/consul/templates/consul_bsdinit.j2 diff --git a/roles/consul/templates/consul_debianinit.j2 b/automation/roles/consul/templates/consul_debianinit.j2 similarity index 100% rename from roles/consul/templates/consul_debianinit.j2 rename to automation/roles/consul/templates/consul_debianinit.j2 diff --git a/roles/consul/templates/consul_launchctl.plist.j2 b/automation/roles/consul/templates/consul_launchctl.plist.j2 similarity index 100% rename from roles/consul/templates/consul_launchctl.plist.j2 rename to automation/roles/consul/templates/consul_launchctl.plist.j2 diff --git a/roles/consul/templates/consul_smf_manifest.j2 b/automation/roles/consul/templates/consul_smf_manifest.j2 similarity index 100% rename from roles/consul/templates/consul_smf_manifest.j2 rename to automation/roles/consul/templates/consul_smf_manifest.j2 diff --git a/roles/consul/templates/consul_snapshot.json.j2 b/automation/roles/consul/templates/consul_snapshot.json.j2 similarity index 100% rename from roles/consul/templates/consul_snapshot.json.j2 rename to automation/roles/consul/templates/consul_snapshot.json.j2 diff --git a/roles/consul/templates/consul_systemd.service.j2 b/automation/roles/consul/templates/consul_systemd.service.j2 similarity index 100% rename from roles/consul/templates/consul_systemd.service.j2 rename to automation/roles/consul/templates/consul_systemd.service.j2 diff --git a/roles/consul/templates/consul_systemd_service.override.j2 b/automation/roles/consul/templates/consul_systemd_service.override.j2 similarity index 100% rename from roles/consul/templates/consul_systemd_service.override.j2 rename to automation/roles/consul/templates/consul_systemd_service.override.j2 diff --git a/roles/consul/templates/consul_systemd_snapshot.service.j2 b/automation/roles/consul/templates/consul_systemd_snapshot.service.j2 similarity index 100% rename from roles/consul/templates/consul_systemd_snapshot.service.j2 rename to automation/roles/consul/templates/consul_systemd_snapshot.service.j2 diff --git a/roles/consul/templates/consul_sysvinit.j2 b/automation/roles/consul/templates/consul_sysvinit.j2 similarity index 100% rename from roles/consul/templates/consul_sysvinit.j2 rename to automation/roles/consul/templates/consul_sysvinit.j2 diff --git a/roles/consul/templates/dnsmasq-10-consul.j2 b/automation/roles/consul/templates/dnsmasq-10-consul.j2 similarity index 100% rename from roles/consul/templates/dnsmasq-10-consul.j2 rename to automation/roles/consul/templates/dnsmasq-10-consul.j2 diff --git a/roles/consul/templates/rsyslogd_00-consul.conf.j2 b/automation/roles/consul/templates/rsyslogd_00-consul.conf.j2 similarity index 100% rename from roles/consul/templates/rsyslogd_00-consul.conf.j2 rename to automation/roles/consul/templates/rsyslogd_00-consul.conf.j2 diff --git a/roles/consul/templates/service.json.j2 b/automation/roles/consul/templates/service.json.j2 similarity index 100% rename from roles/consul/templates/service.json.j2 rename to automation/roles/consul/templates/service.json.j2 diff --git a/roles/consul/templates/syslogng_consul.conf.j2 b/automation/roles/consul/templates/syslogng_consul.conf.j2 similarity index 100% rename from roles/consul/templates/syslogng_consul.conf.j2 rename to automation/roles/consul/templates/syslogng_consul.conf.j2 diff --git a/roles/consul/vars/Amazon.yml b/automation/roles/consul/vars/Amazon.yml similarity index 100% rename from roles/consul/vars/Amazon.yml rename to automation/roles/consul/vars/Amazon.yml diff --git a/roles/consul/vars/Archlinux.yml b/automation/roles/consul/vars/Archlinux.yml similarity index 100% rename from roles/consul/vars/Archlinux.yml rename to automation/roles/consul/vars/Archlinux.yml diff --git a/roles/consul/vars/Darwin.yml b/automation/roles/consul/vars/Darwin.yml similarity index 100% rename from roles/consul/vars/Darwin.yml rename to automation/roles/consul/vars/Darwin.yml diff --git a/roles/consul/vars/Debian.yml b/automation/roles/consul/vars/Debian.yml similarity index 100% rename from roles/consul/vars/Debian.yml rename to automation/roles/consul/vars/Debian.yml diff --git a/roles/consul/vars/Flatcar.yml b/automation/roles/consul/vars/Flatcar.yml similarity index 100% rename from roles/consul/vars/Flatcar.yml rename to automation/roles/consul/vars/Flatcar.yml diff --git a/roles/consul/vars/FreeBSD.yml b/automation/roles/consul/vars/FreeBSD.yml similarity index 100% rename from roles/consul/vars/FreeBSD.yml rename to automation/roles/consul/vars/FreeBSD.yml diff --git a/roles/consul/vars/RedHat.yml b/automation/roles/consul/vars/RedHat.yml similarity index 100% rename from roles/consul/vars/RedHat.yml rename to automation/roles/consul/vars/RedHat.yml diff --git a/roles/consul/vars/Solaris.yml b/automation/roles/consul/vars/Solaris.yml similarity index 100% rename from roles/consul/vars/Solaris.yml rename to automation/roles/consul/vars/Solaris.yml diff --git a/roles/consul/vars/VMware Photon OS.yml b/automation/roles/consul/vars/VMware Photon OS.yml similarity index 100% rename from roles/consul/vars/VMware Photon OS.yml rename to automation/roles/consul/vars/VMware Photon OS.yml diff --git a/roles/consul/vars/Windows.yml b/automation/roles/consul/vars/Windows.yml similarity index 100% rename from roles/consul/vars/Windows.yml rename to automation/roles/consul/vars/Windows.yml diff --git a/roles/consul/vars/main.yml b/automation/roles/consul/vars/main.yml similarity index 100% rename from roles/consul/vars/main.yml rename to automation/roles/consul/vars/main.yml diff --git a/roles/consul/version.txt b/automation/roles/consul/version.txt similarity index 100% rename from roles/consul/version.txt rename to automation/roles/consul/version.txt diff --git a/roles/copy/tasks/main.yml b/automation/roles/copy/tasks/main.yml similarity index 100% rename from roles/copy/tasks/main.yml rename to automation/roles/copy/tasks/main.yml diff --git a/roles/cron/defaults/main.yml b/automation/roles/cron/defaults/main.yml similarity index 100% rename from roles/cron/defaults/main.yml rename to automation/roles/cron/defaults/main.yml diff --git a/roles/cron/tasks/main.yml b/automation/roles/cron/tasks/main.yml similarity index 100% rename from roles/cron/tasks/main.yml rename to automation/roles/cron/tasks/main.yml diff --git a/roles/deploy-finish/tasks/main.yml b/automation/roles/deploy-finish/tasks/main.yml similarity index 100% rename from roles/deploy-finish/tasks/main.yml rename to automation/roles/deploy-finish/tasks/main.yml diff --git a/roles/etc_hosts/tasks/main.yml b/automation/roles/etc_hosts/tasks/main.yml similarity index 100% rename from roles/etc_hosts/tasks/main.yml rename to automation/roles/etc_hosts/tasks/main.yml diff --git a/roles/etcd/tasks/main.yml b/automation/roles/etcd/tasks/main.yml similarity index 100% rename from roles/etcd/tasks/main.yml rename to automation/roles/etcd/tasks/main.yml diff --git a/roles/etcd/templates/etcd.conf.j2 b/automation/roles/etcd/templates/etcd.conf.j2 similarity index 100% rename from roles/etcd/templates/etcd.conf.j2 rename to automation/roles/etcd/templates/etcd.conf.j2 diff --git a/roles/etcd/templates/etcd.service.j2 b/automation/roles/etcd/templates/etcd.service.j2 similarity index 100% rename from roles/etcd/templates/etcd.service.j2 rename to automation/roles/etcd/templates/etcd.service.j2 diff --git a/roles/haproxy/handlers/main.yml b/automation/roles/haproxy/handlers/main.yml similarity index 100% rename from roles/haproxy/handlers/main.yml rename to automation/roles/haproxy/handlers/main.yml diff --git a/roles/haproxy/tasks/main.yml b/automation/roles/haproxy/tasks/main.yml similarity index 100% rename from roles/haproxy/tasks/main.yml rename to automation/roles/haproxy/tasks/main.yml diff --git a/roles/haproxy/templates/haproxy.cfg.j2 b/automation/roles/haproxy/templates/haproxy.cfg.j2 similarity index 100% rename from roles/haproxy/templates/haproxy.cfg.j2 rename to automation/roles/haproxy/templates/haproxy.cfg.j2 diff --git a/roles/haproxy/templates/haproxy.service.j2 b/automation/roles/haproxy/templates/haproxy.service.j2 similarity index 100% rename from roles/haproxy/templates/haproxy.service.j2 rename to automation/roles/haproxy/templates/haproxy.service.j2 diff --git a/roles/hostname/tasks/main.yml b/automation/roles/hostname/tasks/main.yml similarity index 100% rename from roles/hostname/tasks/main.yml rename to automation/roles/hostname/tasks/main.yml diff --git a/roles/io-scheduler/handlers/main.yml b/automation/roles/io-scheduler/handlers/main.yml similarity index 100% rename from roles/io-scheduler/handlers/main.yml rename to automation/roles/io-scheduler/handlers/main.yml diff --git a/roles/io-scheduler/tasks/main.yml b/automation/roles/io-scheduler/tasks/main.yml similarity index 100% rename from roles/io-scheduler/tasks/main.yml rename to automation/roles/io-scheduler/tasks/main.yml diff --git a/roles/io-scheduler/templates/io-scheduler.service.j2 b/automation/roles/io-scheduler/templates/io-scheduler.service.j2 similarity index 100% rename from roles/io-scheduler/templates/io-scheduler.service.j2 rename to automation/roles/io-scheduler/templates/io-scheduler.service.j2 diff --git a/roles/keepalived/defaults/main.yml b/automation/roles/keepalived/defaults/main.yml similarity index 100% rename from roles/keepalived/defaults/main.yml rename to automation/roles/keepalived/defaults/main.yml diff --git a/roles/keepalived/handlers/main.yml b/automation/roles/keepalived/handlers/main.yml similarity index 100% rename from roles/keepalived/handlers/main.yml rename to automation/roles/keepalived/handlers/main.yml diff --git a/roles/keepalived/tasks/main.yml b/automation/roles/keepalived/tasks/main.yml similarity index 100% rename from roles/keepalived/tasks/main.yml rename to automation/roles/keepalived/tasks/main.yml diff --git a/roles/keepalived/templates/keepalived.conf.j2 b/automation/roles/keepalived/templates/keepalived.conf.j2 similarity index 100% rename from roles/keepalived/templates/keepalived.conf.j2 rename to automation/roles/keepalived/templates/keepalived.conf.j2 diff --git a/roles/locales/tasks/main.yml b/automation/roles/locales/tasks/main.yml similarity index 100% rename from roles/locales/tasks/main.yml rename to automation/roles/locales/tasks/main.yml diff --git a/roles/mount/defaults/main.yml b/automation/roles/mount/defaults/main.yml similarity index 100% rename from roles/mount/defaults/main.yml rename to automation/roles/mount/defaults/main.yml diff --git a/roles/mount/tasks/main.yml b/automation/roles/mount/tasks/main.yml similarity index 100% rename from roles/mount/tasks/main.yml rename to automation/roles/mount/tasks/main.yml diff --git a/roles/netdata/tasks/main.yml b/automation/roles/netdata/tasks/main.yml similarity index 100% rename from roles/netdata/tasks/main.yml rename to automation/roles/netdata/tasks/main.yml diff --git a/roles/netdata/templates/netdata.conf.j2 b/automation/roles/netdata/templates/netdata.conf.j2 similarity index 100% rename from roles/netdata/templates/netdata.conf.j2 rename to automation/roles/netdata/templates/netdata.conf.j2 diff --git a/roles/ntp/handlers/main.yml b/automation/roles/ntp/handlers/main.yml similarity index 100% rename from roles/ntp/handlers/main.yml rename to automation/roles/ntp/handlers/main.yml diff --git a/roles/ntp/tasks/main.yml b/automation/roles/ntp/tasks/main.yml similarity index 100% rename from roles/ntp/tasks/main.yml rename to automation/roles/ntp/tasks/main.yml diff --git a/roles/ntp/templates/chrony.conf.j2 b/automation/roles/ntp/templates/chrony.conf.j2 similarity index 100% rename from roles/ntp/templates/chrony.conf.j2 rename to automation/roles/ntp/templates/chrony.conf.j2 diff --git a/roles/ntp/templates/ntp.conf.j2 b/automation/roles/ntp/templates/ntp.conf.j2 similarity index 100% rename from roles/ntp/templates/ntp.conf.j2 rename to automation/roles/ntp/templates/ntp.conf.j2 diff --git a/roles/packages/tasks/extensions.yml b/automation/roles/packages/tasks/extensions.yml similarity index 100% rename from roles/packages/tasks/extensions.yml rename to automation/roles/packages/tasks/extensions.yml diff --git a/roles/packages/tasks/main.yml b/automation/roles/packages/tasks/main.yml similarity index 100% rename from roles/packages/tasks/main.yml rename to automation/roles/packages/tasks/main.yml diff --git a/roles/packages/tasks/perf.yml b/automation/roles/packages/tasks/perf.yml similarity index 100% rename from roles/packages/tasks/perf.yml rename to automation/roles/packages/tasks/perf.yml diff --git a/roles/pam_limits/tasks/main.yml b/automation/roles/pam_limits/tasks/main.yml similarity index 100% rename from roles/pam_limits/tasks/main.yml rename to automation/roles/pam_limits/tasks/main.yml diff --git a/roles/patroni/config/tasks/main.yml b/automation/roles/patroni/config/tasks/main.yml similarity index 100% rename from roles/patroni/config/tasks/main.yml rename to automation/roles/patroni/config/tasks/main.yml diff --git a/roles/patroni/config/tasks/pg_hba.yml b/automation/roles/patroni/config/tasks/pg_hba.yml similarity index 100% rename from roles/patroni/config/tasks/pg_hba.yml rename to automation/roles/patroni/config/tasks/pg_hba.yml diff --git a/roles/patroni/handlers/main.yml b/automation/roles/patroni/handlers/main.yml similarity index 100% rename from roles/patroni/handlers/main.yml rename to automation/roles/patroni/handlers/main.yml diff --git a/roles/patroni/library/yedit.py b/automation/roles/patroni/library/yedit.py similarity index 100% rename from roles/patroni/library/yedit.py rename to automation/roles/patroni/library/yedit.py diff --git a/roles/patroni/tasks/custom_wal_dir.yml b/automation/roles/patroni/tasks/custom_wal_dir.yml similarity index 100% rename from roles/patroni/tasks/custom_wal_dir.yml rename to automation/roles/patroni/tasks/custom_wal_dir.yml diff --git a/roles/patroni/tasks/main.yml b/automation/roles/patroni/tasks/main.yml similarity index 100% rename from roles/patroni/tasks/main.yml rename to automation/roles/patroni/tasks/main.yml diff --git a/roles/patroni/tasks/pip.yml b/automation/roles/patroni/tasks/pip.yml similarity index 100% rename from roles/patroni/tasks/pip.yml rename to automation/roles/patroni/tasks/pip.yml diff --git a/roles/patroni/templates/patroni.service.j2 b/automation/roles/patroni/templates/patroni.service.j2 similarity index 100% rename from roles/patroni/templates/patroni.service.j2 rename to automation/roles/patroni/templates/patroni.service.j2 diff --git a/roles/patroni/templates/patroni.yml.j2 b/automation/roles/patroni/templates/patroni.yml.j2 similarity index 100% rename from roles/patroni/templates/patroni.yml.j2 rename to automation/roles/patroni/templates/patroni.yml.j2 diff --git a/roles/patroni/templates/pg_hba.conf.j2 b/automation/roles/patroni/templates/pg_hba.conf.j2 similarity index 100% rename from roles/patroni/templates/pg_hba.conf.j2 rename to automation/roles/patroni/templates/pg_hba.conf.j2 diff --git a/roles/pg_probackup/tasks/main.yml b/automation/roles/pg_probackup/tasks/main.yml similarity index 100% rename from roles/pg_probackup/tasks/main.yml rename to automation/roles/pg_probackup/tasks/main.yml diff --git a/roles/pgbackrest/stanza-create/tasks/main.yml b/automation/roles/pgbackrest/stanza-create/tasks/main.yml similarity index 100% rename from roles/pgbackrest/stanza-create/tasks/main.yml rename to automation/roles/pgbackrest/stanza-create/tasks/main.yml diff --git a/roles/pgbackrest/tasks/auto_conf.yml b/automation/roles/pgbackrest/tasks/auto_conf.yml similarity index 100% rename from roles/pgbackrest/tasks/auto_conf.yml rename to automation/roles/pgbackrest/tasks/auto_conf.yml diff --git a/roles/pgbackrest/tasks/bootstrap_script.yml b/automation/roles/pgbackrest/tasks/bootstrap_script.yml similarity index 100% rename from roles/pgbackrest/tasks/bootstrap_script.yml rename to automation/roles/pgbackrest/tasks/bootstrap_script.yml diff --git a/roles/pgbackrest/tasks/cron.yml b/automation/roles/pgbackrest/tasks/cron.yml similarity index 100% rename from roles/pgbackrest/tasks/cron.yml rename to automation/roles/pgbackrest/tasks/cron.yml diff --git a/roles/pgbackrest/tasks/main.yml b/automation/roles/pgbackrest/tasks/main.yml similarity index 100% rename from roles/pgbackrest/tasks/main.yml rename to automation/roles/pgbackrest/tasks/main.yml diff --git a/roles/pgbackrest/tasks/ssh_keys.yml b/automation/roles/pgbackrest/tasks/ssh_keys.yml similarity index 100% rename from roles/pgbackrest/tasks/ssh_keys.yml rename to automation/roles/pgbackrest/tasks/ssh_keys.yml diff --git a/roles/pgbackrest/templates/pgbackrest.conf.j2 b/automation/roles/pgbackrest/templates/pgbackrest.conf.j2 similarity index 100% rename from roles/pgbackrest/templates/pgbackrest.conf.j2 rename to automation/roles/pgbackrest/templates/pgbackrest.conf.j2 diff --git a/roles/pgbackrest/templates/pgbackrest.server.conf.j2 b/automation/roles/pgbackrest/templates/pgbackrest.server.conf.j2 similarity index 100% rename from roles/pgbackrest/templates/pgbackrest.server.conf.j2 rename to automation/roles/pgbackrest/templates/pgbackrest.server.conf.j2 diff --git a/roles/pgbackrest/templates/pgbackrest.server.stanza.conf.j2 b/automation/roles/pgbackrest/templates/pgbackrest.server.stanza.conf.j2 similarity index 100% rename from roles/pgbackrest/templates/pgbackrest.server.stanza.conf.j2 rename to automation/roles/pgbackrest/templates/pgbackrest.server.stanza.conf.j2 diff --git a/roles/pgbackrest/templates/pgbackrest_bootstrap.sh.j2 b/automation/roles/pgbackrest/templates/pgbackrest_bootstrap.sh.j2 similarity index 100% rename from roles/pgbackrest/templates/pgbackrest_bootstrap.sh.j2 rename to automation/roles/pgbackrest/templates/pgbackrest_bootstrap.sh.j2 diff --git a/roles/pgbouncer/config/tasks/main.yml b/automation/roles/pgbouncer/config/tasks/main.yml similarity index 100% rename from roles/pgbouncer/config/tasks/main.yml rename to automation/roles/pgbouncer/config/tasks/main.yml diff --git a/roles/pgbouncer/handlers/main.yml b/automation/roles/pgbouncer/handlers/main.yml similarity index 100% rename from roles/pgbouncer/handlers/main.yml rename to automation/roles/pgbouncer/handlers/main.yml diff --git a/roles/pgbouncer/tasks/main.yml b/automation/roles/pgbouncer/tasks/main.yml similarity index 100% rename from roles/pgbouncer/tasks/main.yml rename to automation/roles/pgbouncer/tasks/main.yml diff --git a/roles/pgbouncer/templates/pgbouncer.ini.j2 b/automation/roles/pgbouncer/templates/pgbouncer.ini.j2 similarity index 100% rename from roles/pgbouncer/templates/pgbouncer.ini.j2 rename to automation/roles/pgbouncer/templates/pgbouncer.ini.j2 diff --git a/roles/pgbouncer/templates/pgbouncer.service.j2 b/automation/roles/pgbouncer/templates/pgbouncer.service.j2 similarity index 100% rename from roles/pgbouncer/templates/pgbouncer.service.j2 rename to automation/roles/pgbouncer/templates/pgbouncer.service.j2 diff --git a/roles/pgbouncer/templates/userlist.txt.j2 b/automation/roles/pgbouncer/templates/userlist.txt.j2 similarity index 100% rename from roles/pgbouncer/templates/userlist.txt.j2 rename to automation/roles/pgbouncer/templates/userlist.txt.j2 diff --git a/roles/pgpass/tasks/main.yml b/automation/roles/pgpass/tasks/main.yml similarity index 100% rename from roles/pgpass/tasks/main.yml rename to automation/roles/pgpass/tasks/main.yml diff --git a/roles/postgresql-databases/tasks/main.yml b/automation/roles/postgresql-databases/tasks/main.yml similarity index 100% rename from roles/postgresql-databases/tasks/main.yml rename to automation/roles/postgresql-databases/tasks/main.yml diff --git a/roles/postgresql-extensions/tasks/main.yml b/automation/roles/postgresql-extensions/tasks/main.yml similarity index 100% rename from roles/postgresql-extensions/tasks/main.yml rename to automation/roles/postgresql-extensions/tasks/main.yml diff --git a/roles/postgresql-schemas/tasks/main.yml b/automation/roles/postgresql-schemas/tasks/main.yml similarity index 100% rename from roles/postgresql-schemas/tasks/main.yml rename to automation/roles/postgresql-schemas/tasks/main.yml diff --git a/roles/postgresql-users/tasks/main.yml b/automation/roles/postgresql-users/tasks/main.yml similarity index 100% rename from roles/postgresql-users/tasks/main.yml rename to automation/roles/postgresql-users/tasks/main.yml diff --git a/roles/pre-checks/tasks/extensions.yml b/automation/roles/pre-checks/tasks/extensions.yml similarity index 100% rename from roles/pre-checks/tasks/extensions.yml rename to automation/roles/pre-checks/tasks/extensions.yml diff --git a/roles/pre-checks/tasks/huge_pages.yml b/automation/roles/pre-checks/tasks/huge_pages.yml similarity index 100% rename from roles/pre-checks/tasks/huge_pages.yml rename to automation/roles/pre-checks/tasks/huge_pages.yml diff --git a/roles/pre-checks/tasks/main.yml b/automation/roles/pre-checks/tasks/main.yml similarity index 100% rename from roles/pre-checks/tasks/main.yml rename to automation/roles/pre-checks/tasks/main.yml diff --git a/roles/pre-checks/tasks/passwords.yml b/automation/roles/pre-checks/tasks/passwords.yml similarity index 100% rename from roles/pre-checks/tasks/passwords.yml rename to automation/roles/pre-checks/tasks/passwords.yml diff --git a/roles/pre-checks/tasks/patroni.yml b/automation/roles/pre-checks/tasks/patroni.yml similarity index 100% rename from roles/pre-checks/tasks/patroni.yml rename to automation/roles/pre-checks/tasks/patroni.yml diff --git a/roles/pre-checks/tasks/pgbackrest.yml b/automation/roles/pre-checks/tasks/pgbackrest.yml similarity index 100% rename from roles/pre-checks/tasks/pgbackrest.yml rename to automation/roles/pre-checks/tasks/pgbackrest.yml diff --git a/roles/pre-checks/tasks/pgbouncer.yml b/automation/roles/pre-checks/tasks/pgbouncer.yml similarity index 100% rename from roles/pre-checks/tasks/pgbouncer.yml rename to automation/roles/pre-checks/tasks/pgbouncer.yml diff --git a/roles/pre-checks/tasks/wal_g.yml b/automation/roles/pre-checks/tasks/wal_g.yml similarity index 100% rename from roles/pre-checks/tasks/wal_g.yml rename to automation/roles/pre-checks/tasks/wal_g.yml diff --git a/roles/resolv_conf/tasks/main.yml b/automation/roles/resolv_conf/tasks/main.yml similarity index 100% rename from roles/resolv_conf/tasks/main.yml rename to automation/roles/resolv_conf/tasks/main.yml diff --git a/roles/ssh-keys/tasks/main.yml b/automation/roles/ssh-keys/tasks/main.yml similarity index 100% rename from roles/ssh-keys/tasks/main.yml rename to automation/roles/ssh-keys/tasks/main.yml diff --git a/roles/sudo/tasks/main.yml b/automation/roles/sudo/tasks/main.yml similarity index 100% rename from roles/sudo/tasks/main.yml rename to automation/roles/sudo/tasks/main.yml diff --git a/roles/swap/tasks/main.yml b/automation/roles/swap/tasks/main.yml similarity index 100% rename from roles/swap/tasks/main.yml rename to automation/roles/swap/tasks/main.yml diff --git a/roles/sysctl/tasks/main.yml b/automation/roles/sysctl/tasks/main.yml similarity index 100% rename from roles/sysctl/tasks/main.yml rename to automation/roles/sysctl/tasks/main.yml diff --git a/roles/timezone/tasks/main.yml b/automation/roles/timezone/tasks/main.yml similarity index 100% rename from roles/timezone/tasks/main.yml rename to automation/roles/timezone/tasks/main.yml diff --git a/roles/transparent_huge_pages/handlers/main.yml b/automation/roles/transparent_huge_pages/handlers/main.yml similarity index 100% rename from roles/transparent_huge_pages/handlers/main.yml rename to automation/roles/transparent_huge_pages/handlers/main.yml diff --git a/roles/transparent_huge_pages/tasks/main.yml b/automation/roles/transparent_huge_pages/tasks/main.yml similarity index 100% rename from roles/transparent_huge_pages/tasks/main.yml rename to automation/roles/transparent_huge_pages/tasks/main.yml diff --git a/roles/update/README.md b/automation/roles/update/README.md similarity index 100% rename from roles/update/README.md rename to automation/roles/update/README.md diff --git a/roles/update/tasks/extensions.yml b/automation/roles/update/tasks/extensions.yml similarity index 100% rename from roles/update/tasks/extensions.yml rename to automation/roles/update/tasks/extensions.yml diff --git a/roles/update/tasks/patroni.yml b/automation/roles/update/tasks/patroni.yml similarity index 100% rename from roles/update/tasks/patroni.yml rename to automation/roles/update/tasks/patroni.yml diff --git a/roles/update/tasks/pgbackrest_host.yml b/automation/roles/update/tasks/pgbackrest_host.yml similarity index 100% rename from roles/update/tasks/pgbackrest_host.yml rename to automation/roles/update/tasks/pgbackrest_host.yml diff --git a/roles/update/tasks/postgres.yml b/automation/roles/update/tasks/postgres.yml similarity index 100% rename from roles/update/tasks/postgres.yml rename to automation/roles/update/tasks/postgres.yml diff --git a/roles/update/tasks/pre_checks.yml b/automation/roles/update/tasks/pre_checks.yml similarity index 100% rename from roles/update/tasks/pre_checks.yml rename to automation/roles/update/tasks/pre_checks.yml diff --git a/roles/update/tasks/start_services.yml b/automation/roles/update/tasks/start_services.yml similarity index 100% rename from roles/update/tasks/start_services.yml rename to automation/roles/update/tasks/start_services.yml diff --git a/roles/update/tasks/start_traffic.yml b/automation/roles/update/tasks/start_traffic.yml similarity index 100% rename from roles/update/tasks/start_traffic.yml rename to automation/roles/update/tasks/start_traffic.yml diff --git a/roles/update/tasks/stop_services.yml b/automation/roles/update/tasks/stop_services.yml similarity index 100% rename from roles/update/tasks/stop_services.yml rename to automation/roles/update/tasks/stop_services.yml diff --git a/roles/update/tasks/stop_traffic.yml b/automation/roles/update/tasks/stop_traffic.yml similarity index 100% rename from roles/update/tasks/stop_traffic.yml rename to automation/roles/update/tasks/stop_traffic.yml diff --git a/roles/update/tasks/switchover.yml b/automation/roles/update/tasks/switchover.yml similarity index 100% rename from roles/update/tasks/switchover.yml rename to automation/roles/update/tasks/switchover.yml diff --git a/roles/update/tasks/system.yml b/automation/roles/update/tasks/system.yml similarity index 100% rename from roles/update/tasks/system.yml rename to automation/roles/update/tasks/system.yml diff --git a/roles/update/tasks/update_extensions.yml b/automation/roles/update/tasks/update_extensions.yml similarity index 100% rename from roles/update/tasks/update_extensions.yml rename to automation/roles/update/tasks/update_extensions.yml diff --git a/roles/update/vars/main.yml b/automation/roles/update/vars/main.yml similarity index 100% rename from roles/update/vars/main.yml rename to automation/roles/update/vars/main.yml diff --git a/roles/upgrade/README.md b/automation/roles/upgrade/README.md similarity index 100% rename from roles/upgrade/README.md rename to automation/roles/upgrade/README.md diff --git a/roles/upgrade/tasks/checkpoint_location.yml b/automation/roles/upgrade/tasks/checkpoint_location.yml similarity index 100% rename from roles/upgrade/tasks/checkpoint_location.yml rename to automation/roles/upgrade/tasks/checkpoint_location.yml diff --git a/roles/upgrade/tasks/custom_wal_dir.yml b/automation/roles/upgrade/tasks/custom_wal_dir.yml similarity index 100% rename from roles/upgrade/tasks/custom_wal_dir.yml rename to automation/roles/upgrade/tasks/custom_wal_dir.yml diff --git a/roles/upgrade/tasks/dcs_remove_cluster.yml b/automation/roles/upgrade/tasks/dcs_remove_cluster.yml similarity index 100% rename from roles/upgrade/tasks/dcs_remove_cluster.yml rename to automation/roles/upgrade/tasks/dcs_remove_cluster.yml diff --git a/roles/upgrade/tasks/extensions.yml b/automation/roles/upgrade/tasks/extensions.yml similarity index 100% rename from roles/upgrade/tasks/extensions.yml rename to automation/roles/upgrade/tasks/extensions.yml diff --git a/roles/upgrade/tasks/initdb.yml b/automation/roles/upgrade/tasks/initdb.yml similarity index 100% rename from roles/upgrade/tasks/initdb.yml rename to automation/roles/upgrade/tasks/initdb.yml diff --git a/roles/upgrade/tasks/maintenance_disable.yml b/automation/roles/upgrade/tasks/maintenance_disable.yml similarity index 100% rename from roles/upgrade/tasks/maintenance_disable.yml rename to automation/roles/upgrade/tasks/maintenance_disable.yml diff --git a/roles/upgrade/tasks/maintenance_enable.yml b/automation/roles/upgrade/tasks/maintenance_enable.yml similarity index 100% rename from roles/upgrade/tasks/maintenance_enable.yml rename to automation/roles/upgrade/tasks/maintenance_enable.yml diff --git a/roles/upgrade/tasks/packages.yml b/automation/roles/upgrade/tasks/packages.yml similarity index 100% rename from roles/upgrade/tasks/packages.yml rename to automation/roles/upgrade/tasks/packages.yml diff --git a/roles/upgrade/tasks/pgbouncer_pause.yml b/automation/roles/upgrade/tasks/pgbouncer_pause.yml similarity index 100% rename from roles/upgrade/tasks/pgbouncer_pause.yml rename to automation/roles/upgrade/tasks/pgbouncer_pause.yml diff --git a/roles/upgrade/tasks/pgbouncer_resume.yml b/automation/roles/upgrade/tasks/pgbouncer_resume.yml similarity index 100% rename from roles/upgrade/tasks/pgbouncer_resume.yml rename to automation/roles/upgrade/tasks/pgbouncer_resume.yml diff --git a/roles/upgrade/tasks/post_checks.yml b/automation/roles/upgrade/tasks/post_checks.yml similarity index 100% rename from roles/upgrade/tasks/post_checks.yml rename to automation/roles/upgrade/tasks/post_checks.yml diff --git a/roles/upgrade/tasks/post_upgrade.yml b/automation/roles/upgrade/tasks/post_upgrade.yml similarity index 100% rename from roles/upgrade/tasks/post_upgrade.yml rename to automation/roles/upgrade/tasks/post_upgrade.yml diff --git a/roles/upgrade/tasks/pre_checks.yml b/automation/roles/upgrade/tasks/pre_checks.yml similarity index 100% rename from roles/upgrade/tasks/pre_checks.yml rename to automation/roles/upgrade/tasks/pre_checks.yml diff --git a/roles/upgrade/tasks/rollback.yml b/automation/roles/upgrade/tasks/rollback.yml similarity index 100% rename from roles/upgrade/tasks/rollback.yml rename to automation/roles/upgrade/tasks/rollback.yml diff --git a/roles/upgrade/tasks/schema_compatibility.yml b/automation/roles/upgrade/tasks/schema_compatibility.yml similarity index 100% rename from roles/upgrade/tasks/schema_compatibility.yml rename to automation/roles/upgrade/tasks/schema_compatibility.yml diff --git a/roles/upgrade/tasks/ssh-keys.yml b/automation/roles/upgrade/tasks/ssh-keys.yml similarity index 100% rename from roles/upgrade/tasks/ssh-keys.yml rename to automation/roles/upgrade/tasks/ssh-keys.yml diff --git a/roles/upgrade/tasks/start_services.yml b/automation/roles/upgrade/tasks/start_services.yml similarity index 100% rename from roles/upgrade/tasks/start_services.yml rename to automation/roles/upgrade/tasks/start_services.yml diff --git a/roles/upgrade/tasks/statistics.yml b/automation/roles/upgrade/tasks/statistics.yml similarity index 100% rename from roles/upgrade/tasks/statistics.yml rename to automation/roles/upgrade/tasks/statistics.yml diff --git a/roles/upgrade/tasks/stop_services.yml b/automation/roles/upgrade/tasks/stop_services.yml similarity index 100% rename from roles/upgrade/tasks/stop_services.yml rename to automation/roles/upgrade/tasks/stop_services.yml diff --git a/roles/upgrade/tasks/update_config.yml b/automation/roles/upgrade/tasks/update_config.yml similarity index 100% rename from roles/upgrade/tasks/update_config.yml rename to automation/roles/upgrade/tasks/update_config.yml diff --git a/roles/upgrade/tasks/update_extensions.yml b/automation/roles/upgrade/tasks/update_extensions.yml similarity index 100% rename from roles/upgrade/tasks/update_extensions.yml rename to automation/roles/upgrade/tasks/update_extensions.yml diff --git a/roles/upgrade/tasks/upgrade_check.yml b/automation/roles/upgrade/tasks/upgrade_check.yml similarity index 100% rename from roles/upgrade/tasks/upgrade_check.yml rename to automation/roles/upgrade/tasks/upgrade_check.yml diff --git a/roles/upgrade/tasks/upgrade_primary.yml b/automation/roles/upgrade/tasks/upgrade_primary.yml similarity index 100% rename from roles/upgrade/tasks/upgrade_primary.yml rename to automation/roles/upgrade/tasks/upgrade_primary.yml diff --git a/roles/upgrade/tasks/upgrade_secondary.yml b/automation/roles/upgrade/tasks/upgrade_secondary.yml similarity index 100% rename from roles/upgrade/tasks/upgrade_secondary.yml rename to automation/roles/upgrade/tasks/upgrade_secondary.yml diff --git a/roles/upgrade/templates/haproxy-no-http-checks.cfg.j2 b/automation/roles/upgrade/templates/haproxy-no-http-checks.cfg.j2 similarity index 100% rename from roles/upgrade/templates/haproxy-no-http-checks.cfg.j2 rename to automation/roles/upgrade/templates/haproxy-no-http-checks.cfg.j2 diff --git a/roles/vip-manager/disable/tasks/main.yml b/automation/roles/vip-manager/disable/tasks/main.yml similarity index 100% rename from roles/vip-manager/disable/tasks/main.yml rename to automation/roles/vip-manager/disable/tasks/main.yml diff --git a/roles/vip-manager/handlers/main.yml b/automation/roles/vip-manager/handlers/main.yml similarity index 100% rename from roles/vip-manager/handlers/main.yml rename to automation/roles/vip-manager/handlers/main.yml diff --git a/roles/vip-manager/tasks/main.yml b/automation/roles/vip-manager/tasks/main.yml similarity index 100% rename from roles/vip-manager/tasks/main.yml rename to automation/roles/vip-manager/tasks/main.yml diff --git a/roles/vip-manager/templates/vip-manager.service.j2 b/automation/roles/vip-manager/templates/vip-manager.service.j2 similarity index 100% rename from roles/vip-manager/templates/vip-manager.service.j2 rename to automation/roles/vip-manager/templates/vip-manager.service.j2 diff --git a/roles/vip-manager/templates/vip-manager.yml.j2 b/automation/roles/vip-manager/templates/vip-manager.yml.j2 similarity index 100% rename from roles/vip-manager/templates/vip-manager.yml.j2 rename to automation/roles/vip-manager/templates/vip-manager.yml.j2 diff --git a/roles/wal-g/tasks/auto_conf.yml b/automation/roles/wal-g/tasks/auto_conf.yml similarity index 100% rename from roles/wal-g/tasks/auto_conf.yml rename to automation/roles/wal-g/tasks/auto_conf.yml diff --git a/roles/wal-g/tasks/cron.yml b/automation/roles/wal-g/tasks/cron.yml similarity index 100% rename from roles/wal-g/tasks/cron.yml rename to automation/roles/wal-g/tasks/cron.yml diff --git a/roles/wal-g/tasks/main.yml b/automation/roles/wal-g/tasks/main.yml similarity index 100% rename from roles/wal-g/tasks/main.yml rename to automation/roles/wal-g/tasks/main.yml diff --git a/roles/wal-g/templates/walg.json.j2 b/automation/roles/wal-g/templates/walg.json.j2 similarity index 100% rename from roles/wal-g/templates/walg.json.j2 rename to automation/roles/wal-g/templates/walg.json.j2 diff --git a/tags.md b/automation/tags.md similarity index 100% rename from tags.md rename to automation/tags.md diff --git a/update_pgcluster.yml b/automation/update_pgcluster.yml similarity index 100% rename from update_pgcluster.yml rename to automation/update_pgcluster.yml diff --git a/vars/Debian.yml b/automation/vars/Debian.yml similarity index 100% rename from vars/Debian.yml rename to automation/vars/Debian.yml diff --git a/vars/RedHat.yml b/automation/vars/RedHat.yml similarity index 100% rename from vars/RedHat.yml rename to automation/vars/RedHat.yml diff --git a/vars/main.yml b/automation/vars/main.yml similarity index 100% rename from vars/main.yml rename to automation/vars/main.yml diff --git a/vars/system.yml b/automation/vars/system.yml similarity index 100% rename from vars/system.yml rename to automation/vars/system.yml diff --git a/vars/upgrade.yml b/automation/vars/upgrade.yml similarity index 100% rename from vars/upgrade.yml rename to automation/vars/upgrade.yml diff --git a/console/Dockerfile b/console/Dockerfile new file mode 100644 index 000000000..2843ed1e3 --- /dev/null +++ b/console/Dockerfile @@ -0,0 +1,99 @@ +# build-env +FROM golang:1.22-bookworm AS api-builder +WORKDIR /go/src/pg-console + +COPY console/service/ . + +RUN make build_in_docker + +FROM node:20-bookworm AS ui-builder +WORKDIR /usr/src/pg-console + +COPY console/ui/ . + +RUN yarn install --frozen-lockfile --network-timeout 1000000 && yarn vite build + +# Build the console image +FROM nginx:1.26-bookworm +LABEL maintainer="Vitaliy Kukharik vitabaks@gmail.com" + +COPY --from=api-builder /go/src/pg-console/pg-console /usr/local/bin/ +COPY console/db/migrations /etc/db/migrations +COPY --from=ui-builder /usr/src/pg-console/dist /usr/share/nginx/html/ +COPY console/ui/nginx/nginx.conf /etc/nginx/ +COPY console/ui/env.sh console/ui/.env console/ui/.env.production /usr/share/nginx/html/ +RUN chmod +x /usr/share/nginx/html/env.sh + +ARG POSTGRES_VERSION +ENV POSTGRES_VERSION=${POSTGRES_VERSION:-16} + +ARG POSTGRES_PORT +ENV POSTGRES_PORT=${POSTGRES_PORT:-5432} + +ARG POSTGRES_PASSWORD +ENV POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-"postgres-pass"} + +ARG PGDATA +ENV PGDATA=${PGDATA:-"/var/lib/postgresql/${POSTGRES_VERSION}/main"} + +ARG PG_UNIX_SOCKET_DIR +ENV PG_UNIX_SOCKET_DIR=${PG_UNIX_SOCKET_DIR:-"/var/run/postgresql"} + +ARG PG_CONSOLE_API_PORT +ENV PG_CONSOLE_API_PORT=${PG_CONSOLE_API_PORT:-8080} + +ARG PG_CONSOLE_UI_PORT +ENV PG_CONSOLE_UI_PORT=${PG_CONSOLE_UI_PORT:-80} + +# Set SHELL to /bin/bash +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +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 \ + gnupg postgresql-common apt-transport-https lsb-release openssh-client ca-certificates wget curl vim \ + # PostgreSQL + && install -d /usr/share/postgresql-common/pgdg \ + && curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + && echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update -o Acquire::CompressionTypes::Order::=gz \ + && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y postgresql-${POSTGRES_VERSION} \ + && sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen \ + && pg_dropcluster ${POSTGRES_VERSION} main \ + # TimescaleDB + && wget --quiet -O - https://packagecloud.io/timescale/timescaledb/gpgkey | gpg --dearmor -o /etc/apt/trusted.gpg.d/timescaledb.gpg \ + && echo "deb https://packagecloud.io/timescale/timescaledb/debian/ $(lsb_release -cs) main" | tee /etc/apt/sources.list.d/timescaledb.list \ + && apt-get update -o Acquire::CompressionTypes::Order::=gz \ + && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y timescaledb-2-postgresql-${POSTGRES_VERSION} \ + # supervisor + && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y supervisor + +# Clean up +RUN apt-get autoremove -y --purge gnupg wget \ + && apt-get clean -y autoclean \ + && rm -rf /var/lib/apt/lists/* + +# Copy configuration files +COPY console/db/postgresql.conf /var/tmp/postgresql.conf +COPY console/db/pg_hba.conf /var/tmp/pg_hba.conf + +# Copy pg_start.sh +COPY console/db/pg_start.sh /pg_start.sh +RUN chmod +x /pg_start.sh + +# supervisord [https://docs.docker.com/engine/admin/using_supervisord/] +COPY console/supervisord.conf /etc/supervisor/supervisord.conf + +VOLUME /var/lib/postgresql + +# Console DB +EXPOSE ${POSTGRES_PORT} +# Console API +EXPOSE ${PG_CONSOLE_API_PORT} +# Console UI +EXPOSE ${PG_CONSOLE_UI_PORT} + +# Override the ENTRYPOINT set by nginx image +ENTRYPOINT [] + +CMD ["/usr/bin/supervisord", "--configuration=/etc/supervisor/supervisord.conf", "--nodaemon"] diff --git a/console/db/Dockerfile b/console/db/Dockerfile new file mode 100644 index 000000000..f9b5a3350 --- /dev/null +++ b/console/db/Dockerfile @@ -0,0 +1,56 @@ +FROM debian:12-slim + +ARG POSTGRES_VERSION +ENV POSTGRES_VERSION=${POSTGRES_VERSION:-16} + +ARG POSTGRES_PORT +ENV POSTGRES_PORT=${POSTGRES_PORT:-5432} + +ARG POSTGRES_PASSWORD +ENV POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-"postgres-pass"} + +ARG PGDATA +ENV PGDATA=${PGDATA:-"/var/lib/postgresql/${POSTGRES_VERSION}/main"} + +ARG PG_UNIX_SOCKET_DIR +ENV PG_UNIX_SOCKET_DIR=${PG_UNIX_SOCKET_DIR:-"/var/run/postgresql"} + +# Set SHELL to /bin/bash +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +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 \ + gnupg postgresql-common apt-transport-https lsb-release openssh-client ca-certificates wget curl vim-tiny sudo \ + # PostgreSQL + && install -d /usr/share/postgresql-common/pgdg \ + && curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + && echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update -o Acquire::CompressionTypes::Order::=gz \ + && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y postgresql-${POSTGRES_VERSION} \ + && sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen \ + && pg_dropcluster ${POSTGRES_VERSION} main \ + # TimescaleDB + && wget --quiet -O - https://packagecloud.io/timescale/timescaledb/gpgkey | gpg --dearmor -o /etc/apt/trusted.gpg.d/timescaledb.gpg \ + && echo "deb https://packagecloud.io/timescale/timescaledb/debian/ $(lsb_release -cs) main" | tee /etc/apt/sources.list.d/timescaledb.list \ + && apt-get update -o Acquire::CompressionTypes::Order::=gz \ + && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y timescaledb-2-postgresql-${POSTGRES_VERSION} + +# Clean up +RUN apt-get autoremove -y --purge gnupg wget \ + && apt-get clean -y autoclean \ + && rm -rf /var/lib/apt/lists/* + +# Copy configuration files +COPY console/db/postgresql.conf /var/tmp/postgresql.conf +COPY console/db/pg_hba.conf /var/tmp/pg_hba.conf + +# Copy pg_start.sh +COPY console/db/pg_start.sh /pg_start.sh +RUN chmod +x /pg_start.sh + +VOLUME /var/lib/postgresql + +EXPOSE ${POSTGRES_PORT} + +CMD ["/pg_start.sh"] diff --git a/console/db/README.md b/console/db/README.md new file mode 100644 index 000000000..748b441a7 --- /dev/null +++ b/console/db/README.md @@ -0,0 +1,116 @@ +## Database Schema for PostgreSQL Cluster Console + +### Introduction + +This project uses [Goose](https://github.com/pressly/goose) for versioning and managing database schema changes. Goose is a database migration tool that enables database version control, much like Git does for source code. It allows defining and tracking changes in the database schema over time, ensuring consistency and reproducibility. The backend service is responsible for applying migrations. + +For more information on using Goose, see the [Goose documentation](https://github.com/pressly/goose). + +### Database Migrations +Database migrations are SQL scripts that modify the schema of the database. Each migration script should be placed in the `console/db/migrations` directory and follow Goose's naming convention to ensure they are applied in the correct order. + +**Naming Convention** +Goose uses a specific naming convention to order and apply migrations: + +- Versioned Migrations: These migrations have a version number and are applied in sequence. The naming format is `_.sql` + - Example: `20240520144338_initial_scheme_setup` + - Note: You can use the following command `goose create mogration_file_name sql` to create a new migration file. + +Example migrations: +```shell +goose -dir ./console/db/migrations postgres \ +"host= port=5432 user=postgres password= dbname=" \ +up +``` + +### Validating Migrations + +To check the status of migrations, run: +```shell +goose -dir ./console/db/migrations postgres \ +"host= port=5432 user=postgres password= dbname=" \ +status +``` + +Output example: +``` +status + +2024/05/20 17:50:33 Applied At Migration +2024/05/20 17:50:33 ======================================= +2024/05/20 17:50:33 Mon May 20 14:49:26 2024 -- 20240520144338_2.0.0_initial_scheme_setup.sql +``` + +### Database Schema + +#### Tables: +- `cloud_providers` + - Table containing cloud providers information +- `cloud_regions` + - Table containing cloud regions information for various cloud providers +- `cloud_instances` + - Table containing cloud instances information (including the approximate price) for various cloud providers +- `cloud_volumes` + - Table containing cloud volume information (including the approximate price) for various cloud providers +- `cloud_images` + - Table containing cloud images information for various cloud providers + - Note: For all cloud providers except AWS, the image is the same for all regions. For AWS, the image must be specified for each specific region. +- `secrets` + - Table containing secrets for accessing cloud providers and servers + - Note: The data is encrypted using the pgcrypto extension and a symmetric key. This symmetric key is generated at the application level and is unique for each installation. +- `projects` + - Table containing information about projects + - Default: 'default' +- `environments` + - Table containing information about environments + - Default: 'production', 'staging', 'test', 'dev', 'benchmarking' +- `clusters` + - Table containing information about Postgres clusters +- `servers` + - Table containing information about servers within a Postgres cluster +- `extensions` + - The table stores information about Postgres extensions, including name, description, supported Postgres version range, and whether the extension is a contrib module or third-party. + - 'postgres_min_version' and 'postgres_max_version' define the range of Postgres versions supported by extensions. If the postgres_max_version is NULL, it is assumed that the extension is still supported by new versions of Postgres. +- `operations` + - Table containing logs of operations performed on cluster. + - Note: The migration includes a DO block that checks for the presence of the timescaledb extension. If the extension is installed, the operations table is converted into a hypertable with monthly partitioning. Additionally, the block checks the timescaledb license. If the license is a Community license (timescale), a hypertable compression policy is created for partitions older than one month. +- `postgres_versions` + - Table containing the major PostgreSQL versions supported by the postgresql_cluster +- `settings` + - Table containing configuration parameters, including console and other component settings + +#### Views: +- `v_secrets_list` + - Displays a list of secrets (without revealing secret values) along with additional metadata such as creation and update timestamps. It also includes information about whether each secret is in use and, if so, provides details on which clusters and servers are utilizing the secret. +- `v_operations` + - Displays a list of operations, with additional columns such as the name of the cluster and environment. + +#### Functions: +- `update_server_count` + - Function to update the server_count column in the clusters table. + - Note: This function calculates the number of servers associated with a specific cluster and updates the server_count accordingly. The trigger `update_server_count_trigger` is automatically executed whenever there are INSERT, UPDATE, or DELETE operations on the servers table. This ensures that the server_count in the clusters table is always accurate and up-to-date. +- `add_secret` + - Function to add a secret. + - Usage examples (project_id, secret_type, secret_name, secret_value, encryption_key): + - `SELECT add_secret(1, 'ssh_key', '', '{"private_key": ""}', 'my_encryption_key');` + - `SELECT add_secret(1, 'password', '', '{"username": "", "password": ""}', 'my_encryption_key');` + - `SELECT add_secret(1, 'cloud_secret', '', '{"AWS_ACCESS_KEY_ID": "", "AWS_SECRET_ACCESS_KEY": ""}', 'my_encryption_key');` +- `update_secret` + - Function to update a secret. + - Usage example: + - `SELECT update_secret(, '', '', '', '');` +- `get_secret` + - Function to get a secret value in JSON format. + - Usage example (secret_id, encryption_key): + - `SELECT get_secret(1, 'my_encryption_key');` +- `get_extensions` + - Function to get a list of available extensions in JSON format. All or 'contrib'/'third_party' only (optional). + - Usage examples: + - `SELECT get_extensions(16);` + - `SELECT get_extensions(16, 'contrib');` + - `SELECT get_extensions(16, 'third_party');` +- `get_cluster_name` + - Function to generate a unique name for a new PostgreSQL cluster. + - Note: This function generates names in the format `postgres-cluster-XX`, where `XX` is a sequential number starting from 01. It checks the existing cluster names to ensure the generated name is unique. + - Usage example: + - `SELECT get_cluster_name();` diff --git a/console/db/migrations/20240520144338_2.0.0_initial_scheme_setup.sql b/console/db/migrations/20240520144338_2.0.0_initial_scheme_setup.sql new file mode 100644 index 000000000..27ba92317 --- /dev/null +++ b/console/db/migrations/20240520144338_2.0.0_initial_scheme_setup.sql @@ -0,0 +1,1036 @@ +-- +goose Up + +-- Create extensions +CREATE SCHEMA IF NOT EXISTS extensions; +CREATE EXTENSION IF NOT EXISTS moddatetime SCHEMA extensions; +CREATE EXTENSION IF NOT EXISTS pgcrypto SCHEMA extensions; + +-- cloud_providers +CREATE TABLE public.cloud_providers ( + provider_name text NOT NULL, + provider_description text NOT NULL, + provider_image text +); + +COMMENT ON TABLE public.cloud_providers IS 'Table containing cloud providers information'; +COMMENT ON COLUMN public.cloud_providers.provider_name IS 'The name of the cloud provider'; +COMMENT ON COLUMN public.cloud_providers.provider_description IS 'A description of the cloud provider'; + +INSERT INTO public.cloud_providers (provider_name, provider_description, provider_image) VALUES + ('aws', 'Amazon Web Services', 'aws.png'), + ('gcp', 'Google Cloud Platform', 'gcp.png'), + ('azure', 'Microsoft Azure', 'azure.png'), + ('digitalocean', 'DigitalOcean', 'digitalocean.png'), + ('hetzner', 'Hetzner Cloud', 'hetzner.png'); + +ALTER TABLE ONLY public.cloud_providers + ADD CONSTRAINT cloud_providers_pkey PRIMARY KEY (provider_name); + + +-- cloud_regions +CREATE TABLE public.cloud_regions ( + cloud_provider text NOT NULL, + region_group text NOT NULL, + region_name text NOT NULL, + region_description text NOT NULL +); + +COMMENT ON TABLE public.cloud_regions IS 'Table containing cloud regions information for various cloud providers'; +COMMENT ON COLUMN public.cloud_regions.cloud_provider IS 'The name of the cloud provider'; +COMMENT ON COLUMN public.cloud_regions.region_group IS 'The geographical group of the cloud region'; +COMMENT ON COLUMN public.cloud_regions.region_name IS 'The specific name of the cloud region'; +COMMENT ON COLUMN public.cloud_regions.region_description IS 'A description of the cloud region'; + +INSERT INTO public.cloud_regions (cloud_provider, region_group, region_name, region_description) VALUES + ('aws', 'Africa', 'af-south-1', 'Africa (Cape Town)'), + ('aws', 'Asia Pacific', 'ap-east-1', 'Asia Pacific (Hong Kong)'), + ('aws', 'Asia Pacific', 'ap-south-1', 'Asia Pacific (Mumbai)'), + ('aws', 'Asia Pacific', 'ap-south-2', 'Asia Pacific (Hyderabad)'), + ('aws', 'Asia Pacific', 'ap-southeast-3', 'Asia Pacific (Jakarta)'), + ('aws', 'Asia Pacific', 'ap-southeast-4', 'Asia Pacific (Melbourne)'), + ('aws', 'Asia Pacific', 'ap-northeast-1', 'Asia Pacific (Tokyo)'), + ('aws', 'Asia Pacific', 'ap-northeast-2', 'Asia Pacific (Seoul)'), + ('aws', 'Asia Pacific', 'ap-northeast-3', 'Asia Pacific (Osaka)'), + ('aws', 'Asia Pacific', 'ap-southeast-1', 'Asia Pacific (Singapore)'), + ('aws', 'Asia Pacific', 'ap-southeast-2', 'Asia Pacific (Sydney)'), + ('aws', 'Europe', 'eu-central-1', 'Europe (Frankfurt)'), + ('aws', 'Europe', 'eu-west-1', 'Europe (Ireland)'), + ('aws', 'Europe', 'eu-west-2', 'Europe (London)'), + ('aws', 'Europe', 'eu-west-3', 'Europe (Paris)'), + ('aws', 'Europe', 'eu-north-1', 'Europe (Stockholm)'), + ('aws', 'Europe', 'eu-south-1', 'Europe (Milan)'), + ('aws', 'Europe', 'eu-south-2', 'Europe (Spain)'), + ('aws', 'Europe', 'eu-central-2', 'Europe (Zurich)'), + ('aws', 'Middle East', 'me-south-1', 'Middle East (Bahrain)'), + ('aws', 'Middle East', 'me-central-1', 'Middle East (UAE)'), + ('aws', 'North America', 'us-east-1', 'US East (N. Virginia)'), + ('aws', 'North America', 'us-east-2', 'US East (Ohio)'), + ('aws', 'North America', 'us-west-1', 'US West (N. California)'), + ('aws', 'North America', 'us-west-2', 'US West (Oregon)'), + ('aws', 'North America', 'ca-central-1', 'Canada (Central)'), + ('aws', 'North America', 'ca-west-1', 'Canada (Calgary)'), + ('aws', 'South America', 'sa-east-1', 'South America (São Paulo)'), + ('gcp', 'Africa', 'africa-south1', 'Johannesburg'), + ('gcp', 'Asia Pacific', 'asia-east1', 'Taiwan'), + ('gcp', 'Asia Pacific', 'asia-east2', 'Hong Kong'), + ('gcp', 'Asia Pacific', 'asia-northeast1', 'Tokyo'), + ('gcp', 'Asia Pacific', 'asia-northeast2', 'Osaka'), + ('gcp', 'Asia Pacific', 'asia-northeast3', 'Seoul'), + ('gcp', 'Asia Pacific', 'asia-south1', 'Mumbai'), + ('gcp', 'Asia Pacific', 'asia-south2', 'Delhi'), + ('gcp', 'Asia Pacific', 'asia-southeast1', 'Singapore'), + ('gcp', 'Asia Pacific', 'asia-southeast2', 'Jakarta'), + ('gcp', 'Australia', 'australia-southeast1', 'Sydney'), + ('gcp', 'Australia', 'australia-southeast2', 'Melbourne'), + ('gcp', 'Europe', 'europe-central2', 'Warsaw'), + ('gcp', 'Europe', 'europe-north1', 'Finland'), + ('gcp', 'Europe', 'europe-southwest1', 'Madrid'), + ('gcp', 'Europe', 'europe-west1', 'Belgium'), + ('gcp', 'Europe', 'europe-west10', 'Berlin'), + ('gcp', 'Europe', 'europe-west12', 'Turin'), + ('gcp', 'Europe', 'europe-west2', 'London'), + ('gcp', 'Europe', 'europe-west3', 'Frankfurt'), + ('gcp', 'Europe', 'europe-west4', 'Netherlands'), + ('gcp', 'Europe', 'europe-west6', 'Zurich'), + ('gcp', 'Europe', 'europe-west8', 'Milan'), + ('gcp', 'Europe', 'europe-west9', 'Paris'), + ('gcp', 'Middle East', 'me-central1', 'Doha'), + ('gcp', 'Middle East', 'me-central2', 'Dammam'), + ('gcp', 'Middle East', 'me-west1', 'Tel Aviv'), + ('gcp', 'North America', 'northamerica-northeast1', 'Montréal'), + ('gcp', 'North America', 'northamerica-northeast2', 'Toronto'), + ('gcp', 'North America', 'us-central1', 'Iowa'), + ('gcp', 'North America', 'us-east1', 'South Carolina'), + ('gcp', 'North America', 'us-east4', 'Northern Virginia'), + ('gcp', 'North America', 'us-east5', 'Columbus'), + ('gcp', 'North America', 'us-south1', 'Dallas'), + ('gcp', 'North America', 'us-west1', 'Oregon'), + ('gcp', 'North America', 'us-west2', 'Los Angeles'), + ('gcp', 'North America', 'us-west3', 'Salt Lake City'), + ('gcp', 'North America', 'us-west4', 'Las Vegas'), + ('gcp', 'South America', 'southamerica-east1', 'São Paulo'), + ('gcp', 'South America', 'southamerica-west1', 'Santiago'), + ('azure', 'Africa', 'southafricanorth', 'South Africa North (Johannesburg)'), + ('azure', 'Africa', 'southafricawest', 'South Africa West (Cape Town)'), + ('azure', 'Asia Pacific', 'australiacentral', 'Australia Central (Canberra)'), + ('azure', 'Asia Pacific', 'australiacentral2', 'Australia Central 2 (Canberra)'), + ('azure', 'Asia Pacific', 'australiaeast', 'Australia East (New South Wales)'), + ('azure', 'Asia Pacific', 'australiasoutheast', 'Australia Southeast (Victoria)'), + ('azure', 'Asia Pacific', 'centralindia', 'Central India (Pune)'), + ('azure', 'Asia Pacific', 'eastasia', 'East Asia (Hong Kong)'), + ('azure', 'Asia Pacific', 'japaneast', 'Japan East (Tokyo, Saitama)'), + ('azure', 'Asia Pacific', 'japanwest', 'Japan West (Osaka)'), + ('azure', 'Asia Pacific', 'jioindiacentral', 'Jio India Central (Nagpur)'), + ('azure', 'Asia Pacific', 'jioindiawest', 'Jio India West (Jamnagar)'), + ('azure', 'Asia Pacific', 'koreacentral', 'Korea Central (Seoul)'), + ('azure', 'Asia Pacific', 'koreasouth', 'Korea South (Busan)'), + ('azure', 'Asia Pacific', 'southeastasia', 'Southeast Asia (Singapore)'), + ('azure', 'Asia Pacific', 'southindia', 'South India (Chennai)'), + ('azure', 'Asia Pacific', 'westindia', 'West India (Mumbai)'), + ('azure', 'Europe', 'francecentral', 'France Central (Paris)'), + ('azure', 'Europe', 'francesouth', 'France South (Marseille)'), + ('azure', 'Europe', 'germanynorth', 'Germany North (Berlin)'), + ('azure', 'Europe', 'germanywestcentral', 'Germany West Central (Frankfurt)'), + ('azure', 'Europe', 'italynorth', 'Italy North (Milan)'), + ('azure', 'Europe', 'northeurope', 'North Europe (Ireland)'), + ('azure', 'Europe', 'norwayeast', 'Norway East (Norway)'), + ('azure', 'Europe', 'norwaywest', 'Norway West (Norway)'), + ('azure', 'Europe', 'polandcentral', 'Poland Central (Warsaw)'), + ('azure', 'Europe', 'swedencentral', 'Sweden Central (Gävle)'), + ('azure', 'Europe', 'switzerlandnorth', 'Switzerland North (Zurich)'), + ('azure', 'Europe', 'switzerlandwest', 'Switzerland West (Geneva)'), + ('azure', 'Europe', 'uksouth', 'UK South (London)'), + ('azure', 'Europe', 'ukwest', 'UK West (Cardiff)'), + ('azure', 'Europe', 'westeurope', 'West Europe (Netherlands)'), + ('azure', 'Mexico', 'mexicocentral', 'Mexico Central (Querétaro State)'), + ('azure', 'Middle East', 'qatarcentral', 'Qatar Central (Doha)'), + ('azure', 'Middle East', 'uaecentral', 'UAE Central (Abu Dhabi)'), + ('azure', 'Middle East', 'uaenorth', 'UAE North (Dubai)'), + ('azure', 'South America', 'brazilsouth', 'Brazil South (Sao Paulo State)'), + ('azure', 'South America', 'brazilsoutheast', 'Brazil Southeast (Rio)'), + ('azure', 'North America', 'centralus', 'Central US (Iowa)'), + ('azure', 'North America', 'eastus', 'East US (Virginia)'), + ('azure', 'North America', 'eastus2', 'East US 2 (Virginia)'), + ('azure', 'North America', 'eastusstg', 'East US STG (Virginia)'), + ('azure', 'North America', 'northcentralus', 'North Central US (Illinois)'), + ('azure', 'North America', 'southcentralus', 'South Central US (Texas)'), + ('azure', 'North America', 'westcentralus', 'West Central US (Wyoming)'), + ('azure', 'North America', 'westus', 'West US (California)'), + ('azure', 'North America', 'westus2', 'West US 2 (Washington)'), + ('azure', 'North America', 'westus3', 'West US 3 (Phoenix)'), + ('azure', 'North America', 'canadaeast', 'Canada East (Quebec)'), + ('azure', 'North America', 'canadacentral', 'Canada Central (Toronto)'), + ('azure', 'South America', 'brazilus', 'Brazil US (South America)'), + ('digitalocean', 'Asia Pacific', 'sgp1', 'Singapore (Datacenter 1)'), + ('digitalocean', 'Asia Pacific', 'blr1', 'Bangalore (Datacenter 1)'), + ('digitalocean', 'Australia', 'syd1', 'Sydney (Datacenter 1)'), + ('digitalocean', 'Europe', 'ams3', 'Amsterdam (Datacenter 3)'), + ('digitalocean', 'Europe', 'lon1', 'London (Datacenter 1)'), + ('digitalocean', 'Europe', 'fra1', 'Frankfurt (Datacenter 1)'), + ('digitalocean', 'North America', 'nyc1', 'New York (Datacenter 1)'), + ('digitalocean', 'North America', 'nyc3', 'New York (Datacenter 3)'), + ('digitalocean', 'North America', 'sfo2', 'San Francisco (Datacenter 2)'), + ('digitalocean', 'North America', 'sfo3', 'San Francisco (Datacenter 3)'), + ('digitalocean', 'North America', 'tor1', 'Toronto (Datacenter 1)'), + ('hetzner', 'Europe', 'nbg1', 'Nuremberg'), + ('hetzner', 'Europe', 'fsn1', 'Falkenstein'), + ('hetzner', 'Europe', 'hel1', 'Helsinki'), + ('hetzner', 'North America', 'hil', 'Hillsboro, OR'), + ('hetzner', 'North America', 'ash', 'Ashburn, VA'), + ('hetzner', 'Asia Pacific', 'sin', 'Singapore'); + +ALTER TABLE ONLY public.cloud_regions + ADD CONSTRAINT cloud_regions_pkey PRIMARY KEY (cloud_provider, region_group, region_name); + +ALTER TABLE ONLY public.cloud_regions + ADD CONSTRAINT cloud_regions_cloud_provider_fkey FOREIGN KEY (cloud_provider) REFERENCES public.cloud_providers(provider_name); + + +-- cloud_instances +CREATE TABLE public.cloud_instances ( + cloud_provider text NOT NULL, + instance_group text NOT NULL, + instance_name text NOT NULL, + arch text DEFAULT 'amd64' NOT NULL, + cpu integer NOT NULL, + ram integer NOT NULL, + price_hourly numeric NOT NULL, + price_monthly numeric NOT NULL, + currency CHAR(1) DEFAULT '$' NOT NULL, + updated_at timestamp DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE public.cloud_instances IS 'Table containing cloud instances information for various cloud providers'; +COMMENT ON COLUMN public.cloud_instances.cloud_provider IS 'The name of the cloud provider'; +COMMENT ON COLUMN public.cloud_instances.instance_group IS 'The group of the instance size'; +COMMENT ON COLUMN public.cloud_instances.instance_name IS 'The specific name of the cloud instance'; +COMMENT ON COLUMN public.cloud_instances.arch IS 'The architecture of the instance'; +COMMENT ON COLUMN public.cloud_instances.cpu IS 'The number of CPUs of the instance'; +COMMENT ON COLUMN public.cloud_instances.ram IS 'The amount of RAM (in GB) of the instance'; +COMMENT ON COLUMN public.cloud_instances.price_hourly IS 'The hourly price of the instance'; +COMMENT ON COLUMN public.cloud_instances.price_monthly IS 'The monthly price of the instance'; +COMMENT ON COLUMN public.cloud_instances.currency IS 'The currency of the price (default: $)'; +COMMENT ON COLUMN public.cloud_instances.updated_at IS 'The date when the instance information was last updated'; + +-- The price is approximate because it is specified for one region and may differ in other regions. +-- aws, gcp, azure: the price is for the region 'US East' +INSERT INTO public.cloud_instances (cloud_provider, instance_group, instance_name, cpu, ram, price_hourly, price_monthly, currency, updated_at) VALUES + ('aws', 'Small Size', 't3.small', 2, 2, 0.021, 14.976, '$', '2024-05-15'), + ('aws', 'Small Size', 't3.medium', 2, 4, 0.042, 29.952, '$', '2024-05-15'), + ('aws', 'Small Size', 'm6i.large', 2, 8, 0.096, 69.120, '$', '2024-05-15'), + ('aws', 'Small Size', 'r6i.large', 2, 16, 0.126, 90.720, '$', '2024-05-15'), + ('aws', 'Small Size', 'm6i.xlarge', 4, 16, 0.192, 138.240, '$', '2024-05-15'), + ('aws', 'Small Size', 'r6i.xlarge', 4, 32, 0.252, 181.440, '$', '2024-05-15'), + ('aws', 'Medium Size', 'm6i.2xlarge', 8, 32, 0.384, 276.480, '$', '2024-05-15'), + ('aws', 'Medium Size', 'r6i.2xlarge', 8, 64, 0.504, 362.880, '$', '2024-05-15'), + ('aws', 'Medium Size', 'm6i.4xlarge', 16, 64, 0.768, 552.960, '$', '2024-05-15'), + ('aws', 'Medium Size', 'r6i.4xlarge', 16, 128, 1.008, 725.760, '$', '2024-05-15'), + ('aws', 'Medium Size', 'm6i.8xlarge', 32, 128, 1.536, 1105.920, '$', '2024-05-15'), + ('aws', 'Medium Size', 'r6i.8xlarge', 32, 256, 2.016, 1451.520, '$', '2024-05-15'), + ('aws', 'Medium Size', 'm6i.12xlarge', 48, 192, 2.304, 1658.880, '$', '2024-05-15'), + ('aws', 'Medium Size', 'r6i.12xlarge', 48, 384, 3.024, 2177.280, '$', '2024-05-15'), + ('aws', 'Large Size', 'm6i.16xlarge', 64, 256, 3.072, 2211.840, '$', '2024-05-15'), + ('aws', 'Large Size', 'r6i.16xlarge', 64, 512, 4.032, 2903.040, '$', '2024-05-15'), + ('aws', 'Large Size', 'm6i.24xlarge', 96, 384, 4.608, 3317.760, '$', '2024-05-15'), + ('aws', 'Large Size', 'r6i.24xlarge', 96, 768, 6.048, 4354.560, '$', '2024-05-15'), + ('aws', 'Large Size', 'm6i.32xlarge', 128, 512, 6.144, 4423.680, '$', '2024-05-15'), + ('aws', 'Large Size', 'r6i.32xlarge', 128, 1024, 8.064, 5806.080, '$', '2024-05-15'), + ('aws', 'Large Size', 'm7i.48xlarge', 192, 768, 9.677, 6967.296, '$', '2024-05-15'), + ('aws', 'Large Size', 'r7i.48xlarge', 192, 1536, 12.701, 9144.576, '$', '2024-05-15'), + ('gcp', 'Small Size', 'e2-small', 2, 2, 0.017, 12.228, '$', '2024-05-15'), + ('gcp', 'Small Size', 'e2-medium', 2, 4, 0.034, 24.457, '$', '2024-05-15'), + ('gcp', 'Small Size', 'n2-standard-2', 2, 8, 0.097, 70.896, '$', '2024-05-15'), + ('gcp', 'Small Size', 'n2-highmem-2', 2, 16, 0.131, 95.640, '$', '2024-05-15'), + ('gcp', 'Small Size', 'n2-standard-4', 4, 16, 0.194, 141.792, '$', '2024-05-15'), + ('gcp', 'Small Size', 'n2-highmem-4', 4, 32, 0.262, 191.280, '$', '2024-05-15'), + ('gcp', 'Medium Size', 'n2-standard-8', 8, 32, 0.388, 283.585, '$', '2024-05-15'), + ('gcp', 'Medium Size', 'n2-highmem-8', 8, 64, 0.524, 382.561, '$', '2024-05-15'), + ('gcp', 'Medium Size', 'n2-standard-16', 16, 64, 0.777, 567.169, '$', '2024-05-15'), + ('gcp', 'Medium Size', 'n2-highmem-16', 16, 128, 1.048, 765.122, '$', '2024-05-15'), + ('gcp', 'Medium Size', 'n2-standard-32', 32, 128, 1.554, 1134.338, '$', '2024-05-15'), + ('gcp', 'Medium Size', 'n2-highmem-32', 32, 256, 2.096, 1530.244, '$', '2024-05-15'), + ('gcp', 'Medium Size', 'n2-standard-48', 48, 192, 2.331, 1701.507, '$', '2024-05-15'), + ('gcp', 'Medium Size', 'n2-highmem-48', 48, 384, 3.144, 2295.365, '$', '2024-05-15'), + ('gcp', 'Large Size', 'n2-standard-64', 64, 256, 3.108, 2268.676, '$', '2024-05-15'), + ('gcp', 'Large Size', 'n2-highmem-64', 64, 512, 4.192, 3060.487, '$', '2024-05-15'), + ('gcp', 'Large Size', 'n2-standard-80', 80, 320, 3.885, 2835.846, '$', '2024-05-15'), + ('gcp', 'Large Size', 'n2-highmem-80', 80, 640, 5.241, 3825.609, '$', '2024-05-15'), + ('gcp', 'Large Size', 'n2-standard-96', 96, 384, 4.662, 3403.015, '$', '2024-05-15'), + ('gcp', 'Large Size', 'n2-highmem-96', 96, 768, 6.289, 4590.731, '$', '2024-05-15'), + ('gcp', 'Large Size', 'n2-standard-128', 128, 512, 6.216, 4537.353, '$', '2024-05-15'), + ('gcp', 'Large Size', 'n2-highmem-128', 128, 864, 7.707, 5626.092, '$', '2024-05-15'), + ('gcp', 'Large Size', 'c3-standard-176', 176, 704, 9.188, 6706.913, '$', '2024-05-15'), + ('gcp', 'Large Size', 'c3-highmem-176', 176, 1408, 12.394, 9047.819, '$', '2024-05-15'), + ('azure', 'Small Size', 'Standard_B1ms', 1, 2, 0.021, 15.111, '$', '2024-05-15'), + ('azure', 'Small Size', 'Standard_B2s', 2, 4, 0.042, 30.368, '$', '2024-05-15'), + ('azure', 'Small Size', 'Standard_D2s_v5', 2, 8, 0.096, 70.080, '$', '2024-05-15'), + ('azure', 'Small Size', 'Standard_E2s_v5', 2, 16, 0.126, 91.980, '$', '2024-05-15'), + ('azure', 'Small Size', 'Standard_D4s_v5', 4, 16, 0.192, 140.160, '$', '2024-05-15'), + ('azure', 'Small Size', 'Standard_E4s_v5', 4, 32, 0.252, 183.960, '$', '2024-05-15'), + ('azure', 'Medium Size', 'Standard_D8s_v5', 8, 32, 0.384, 280.320, '$', '2024-05-15'), + ('azure', 'Medium Size', 'Standard_E8s_v5', 8, 64, 0.504, 367.920, '$', '2024-05-15'), + ('azure', 'Medium Size', 'Standard_D16s_v5', 16, 64, 0.768, 560.640, '$', '2024-05-15'), + ('azure', 'Medium Size', 'Standard_E16s_v5', 16, 128, 1.008, 735.840, '$', '2024-05-15'), + ('azure', 'Medium Size', 'Standard_D32s_v5', 32, 128, 1.536, 1121.280, '$', '2024-05-15'), + ('azure', 'Medium Size', 'Standard_E32s_v5', 32, 256, 2.016, 1471.680, '$', '2024-05-15'), + ('azure', 'Large Size', 'Standard_D48s_v5', 48, 192, 2.304, 1681.920, '$', '2024-05-15'), + ('azure', 'Large Size', 'Standard_E48s_v5', 48, 384, 3.024, 2207.520, '$', '2024-05-15'), + ('azure', 'Large Size', 'Standard_D64s_v5', 64, 256, 3.072, 2242.560, '$', '2024-05-15'), + ('azure', 'Large Size', 'Standard_E64s_v5', 64, 512, 4.032, 2943.360, '$', '2024-05-15'), + ('azure', 'Large Size', 'Standard_D96s_v5', 96, 384, 4.608, 3363.840, '$', '2024-05-15'), + ('azure', 'Large Size', 'Standard_E96s_v5', 96, 672, 6.048, 4415.040, '$', '2024-05-15'), + ('digitalocean', 'Small Size', 's-2vcpu-2gb', 2, 2, 0.027, 18.000, '$', '2024-05-15'), + ('digitalocean', 'Small Size', 's-2vcpu-4gb', 2, 4, 0.036, 24.000, '$', '2024-05-15'), + ('digitalocean', 'Small Size', 'g-2vcpu-8gb', 2, 8, 0.094, 63.000, '$', '2024-05-15'), + ('digitalocean', 'Small Size', 'm-2vcpu-16gb', 2, 16, 0.125, 84.000, '$', '2024-05-15'), + ('digitalocean', 'Small Size', 'g-4vcpu-16gb', 4, 16, 0.188, 126.000, '$', '2024-05-15'), + ('digitalocean', 'Small Size', 'm-4vcpu-32gb', 4, 32, 0.250, 168.000, '$', '2024-05-15'), + ('digitalocean', 'Medium Size', 'g-8vcpu-32gb', 8, 32, 0.375, 252.000, '$', '2024-05-15'), + ('digitalocean', 'Medium Size', 'm-8vcpu-64gb', 8, 64, 0.500, 336.000, '$', '2024-05-15'), + ('digitalocean', 'Medium Size', 'g-16vcpu-64gb', 16, 64, 0.750, 504.000, '$', '2024-05-15'), + ('digitalocean', 'Medium Size', 'm-16vcpu-128gb', 16, 128, 1.000, 672.000, '$', '2024-05-15'), + ('digitalocean', 'Medium Size', 'g-32vcpu-128gb', 32, 128, 1.500, 1008.000, '$', '2024-05-15'), + ('digitalocean', 'Medium Size', 'm-32vcpu-256gb', 32, 256, 2.000, 1344.000, '$', '2024-05-15'), + ('digitalocean', 'Medium Size', 'g-48vcpu-192gb', 48, 192, 2.699, 1814.000, '$', '2024-05-15'), + ('hetzner', 'Small Size', 'CPX11', 2, 2, 0.007, 5.180, '€', '2024-07-21'), + ('hetzner', 'Small Size', 'CPX21', 3, 4, 0.010, 8.980, '€', '2024-07-21'), + ('hetzner', 'Small Size', 'CCX13', 2, 8, 0.024, 14.860, '€', '2024-05-15'), + ('hetzner', 'Small Size', 'CCX23', 4, 16, 0.047, 29.140, '€', '2024-05-15'), + ('hetzner', 'Medium Size', 'CCX33', 8, 32, 0.093, 57.700, '€', '2024-05-15'), + ('hetzner', 'Medium Size', 'CCX43', 16, 64, 0.184, 114.820, '€', '2024-05-15'), + ('hetzner', 'Medium Size', 'CCX53', 32, 128, 0.367, 229.060, '€', '2024-05-15'), + ('hetzner', 'Medium Size', 'CCX63', 48, 192, 0.550, 343.300, '€', '2024-05-15'); + +ALTER TABLE ONLY public.cloud_instances + ADD CONSTRAINT cloud_instances_pkey PRIMARY KEY (cloud_provider, instance_group, instance_name); + +ALTER TABLE ONLY public.cloud_instances + ADD CONSTRAINT cloud_instances_cloud_provider_fkey FOREIGN KEY (cloud_provider) REFERENCES public.cloud_providers(provider_name); + +-- this trigger will set the "updated_at" column to the current timestamp for every update +CREATE TRIGGER handle_updated_at BEFORE UPDATE ON public.cloud_instances + FOR EACH ROW EXECUTE FUNCTION extensions.moddatetime (updated_at); + + +-- cloud_volumes +CREATE TABLE public.cloud_volumes ( + cloud_provider text NOT NULL, + volume_type text NOT NULL, + volume_description text NOT NULL, + volume_min_size integer NOT NULL, + volume_max_size integer NOT NULL, + price_monthly numeric NOT NULL, + currency CHAR(1) DEFAULT '$' NOT NULL, + is_default boolean NOT NULL DEFAULT false, + updated_at timestamp DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE public.cloud_volumes IS 'Table containing cloud volume information for various cloud providers'; +COMMENT ON COLUMN public.cloud_volumes.cloud_provider IS 'The name of the cloud provider'; +COMMENT ON COLUMN public.cloud_volumes.volume_type IS 'The type of the volume (the name provided by the API)'; +COMMENT ON COLUMN public.cloud_volumes.volume_description IS 'Description of the volume'; +COMMENT ON COLUMN public.cloud_volumes.volume_min_size IS 'The minimum size of the volume (in GB)'; +COMMENT ON COLUMN public.cloud_volumes.volume_max_size IS 'The maximum size of the volume (in GB)'; +COMMENT ON COLUMN public.cloud_volumes.price_monthly IS 'The monthly price per GB of the volume'; +COMMENT ON COLUMN public.cloud_volumes.currency IS 'The currency of the price (default: $)'; +COMMENT ON COLUMN public.cloud_volumes.is_default IS 'Indicates if the volume type is the default'; +COMMENT ON COLUMN public.cloud_volumes.updated_at IS 'The date when the volume information was last updated'; + +-- The price is approximate because it is specified for one region and may differ in other regions. +-- aws, gcp, azure: the price is for the region 'US East' +INSERT INTO public.cloud_volumes (cloud_provider, volume_type, volume_description, volume_min_size, volume_max_size, price_monthly, currency, is_default, updated_at) VALUES + ('aws', 'st1', 'Throughput Optimized HDD Disk (Max throughput: 500 MiB/s, Max IOPS: 500)', 125, 16000, 0.045, '$', false, '2024-05-15'), + ('aws', 'gp3', 'General Purpose SSD Disk (Max throughput: 1,000 MiB/s, Max IOPS: 16,000)', 10, 16000, 0.080, '$', true, '2024-05-15'), + ('aws', 'io2', 'Provisioned IOPS SSD Disk (Max throughput: 4,000 MiB/s, Max IOPS: 256,000)', 10, 64000, 0.125, '$', false, '2024-05-15'), + ('gcp', 'pd-standard', 'Standard Persistent HDD Disk (Max throughput: 180 MiB/s, Max IOPS: 3,000)', 10, 64000, 0.040, '$', false, '2024-05-15'), + ('gcp', 'pd-balanced', 'Balanced Persistent SSD Disk (Max throughput: 240 MiB/s, Max IOPS: 15,000)', 10, 64000, 0.100, '$', false, '2024-05-15'), + ('gcp', 'pd-ssd', 'SSD Persistent Disk (Max throughput: 1,200 MiB/s, Max IOPS: 100,000)', 10, 64000, 0.170, '$', true, '2024-05-15'), + ('gcp', 'pd-extreme', 'Extreme Persistent SSD Disk (Max throughput: 2,400 MiB/s, Max IOPS: 120,000)', 500, 64000, 0.125, '$', false, '2024-05-15'), + ('azure', 'Standard_LRS', 'Standard HDD (Max throughput: 500 MiB/s, Max IOPS: 2,000)', 10, 32000, 0.040, '$', false, '2024-05-15'), + ('azure', 'StandardSSD_LRS', 'Standard SSD (Max throughput: 750 MiB/s, Max IOPS: 6,000)', 10, 32000, 0.075, '$', true, '2024-05-15'), + ('azure', 'Premium_LRS', 'Premium SSD (Max throughput: 900 MiB/s, Max IOPS: 20,000)', 10, 32000, 0.132, '$', false, '2024-05-15'), + ('azure', 'UltraSSD_LRS', 'Ultra SSD (Max throughput: 10,000 MiB/s, Max IOPS: 400,000)', 10, 64000, 0.120, '$', false, '2024-05-15'), + ('digitalocean', 'ssd', 'SSD Block Storage (Max throughput: 300 MiB/s, Max IOPS: 7,500)', 10, 16000, 0.100, '$', true, '2024-05-15'), + ('hetzner', 'ssd', 'SSD Block Storage (Max throughput: N/A MiB/s, Max IOPS: N/A)', 10, 10000, 0.052, '€', true, '2024-05-15'); + +ALTER TABLE ONLY public.cloud_volumes + ADD CONSTRAINT cloud_volumes_pkey PRIMARY KEY (cloud_provider, volume_type); + +ALTER TABLE ONLY public.cloud_volumes + ADD CONSTRAINT cloud_volumes_cloud_provider_fkey FOREIGN KEY (cloud_provider) REFERENCES public.cloud_providers(provider_name); + +CREATE TRIGGER handle_updated_at BEFORE UPDATE ON public.cloud_volumes + FOR EACH ROW EXECUTE FUNCTION extensions.moddatetime (updated_at); + + +-- cloud_images +CREATE TABLE public.cloud_images ( + cloud_provider text NOT NULL, + region text NOT NULL, + image jsonb NOT NULL, + arch text DEFAULT 'amd64' NOT NULL, + os_name text NOT NULL, + os_version text NOT NULL, + updated_at timestamp DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE public.cloud_images IS 'Table containing cloud images information for various cloud providers'; +COMMENT ON COLUMN public.cloud_images.cloud_provider IS 'The name of the cloud provider'; +COMMENT ON COLUMN public.cloud_images.region IS 'The region where the image is available'; +COMMENT ON COLUMN public.cloud_images.image IS 'The image details in JSON format {"variable_name": "value"}'; +COMMENT ON COLUMN public.cloud_images.arch IS 'The architecture of the operating system (default: amd64)'; +COMMENT ON COLUMN public.cloud_images.os_name IS 'The name of the operating system'; +COMMENT ON COLUMN public.cloud_images.os_version IS 'The version of the operating system'; +COMMENT ON COLUMN public.cloud_images.updated_at IS 'The date when the image information was last updated'; + +-- For all cloud providers except AWS, the image is the same for all regions. +-- For AWS, the image must be specified for each specific region. +-- The value of the "image" column is set in the format: '{"variable_name": "value"}' +-- This format provides flexibility to specify different variables for different cloud providers. +-- For example, Azure requires four variables instead of a single "server_image": +-- azure_vm_image_offer, azure_vm_image_publisher, azure_vm_image_sku, azure_vm_image_version. +INSERT INTO public.cloud_images (cloud_provider, region, image, arch, os_name, os_version, updated_at) VALUES + ('aws', 'ap-south-2', '{"server_image": "ami-07c29982fe3ae5d4a"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'ap-south-1', '{"server_image": "ami-01c893e7f232d634f"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'eu-south-1', '{"server_image": "ami-0ef03f8ff5bbf854c"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'eu-south-2', '{"server_image": "ami-0e37953c2e92990cd"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'me-central-1', '{"server_image": "ami-028258249d6efbb44"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'ca-central-1', '{"server_image": "ami-0019e788a5e62c6e4"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'eu-central-1', '{"server_image": "ami-0ac67c1f8689447a6"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'eu-central-2', '{"server_image": "ami-0ac79a44f0ec70fe1"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'us-west-1', '{"server_image": "ami-0947011e21ec8788d"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'us-west-2', '{"server_image": "ami-0ca5d4e146b3ba5bf"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'af-south-1', '{"server_image": "ami-0ff5d1627e39b443d"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'eu-north-1', '{"server_image": "ami-035542f8c972d7edf"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'eu-west-3', '{"server_image": "ami-0ba794d79cd225039"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'eu-west-2', '{"server_image": "ami-0bc743bd935283b7f"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'eu-west-1', '{"server_image": "ami-09c7c04446217191d"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'ap-northeast-3', '{"server_image": "ami-0bfe27a707728ee11"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'ap-northeast-2', '{"server_image": "ami-02d2c9994ab378951"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'me-south-1', '{"server_image": "ami-009d9f02cfb388154"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'ap-northeast-1', '{"server_image": "ami-0aa80c152f0b55a7e"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'sa-east-1', '{"server_image": "ami-0f381f7a86e649eb5"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'ap-east-1', '{"server_image": "ami-0bb44258f22410dc4"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'ca-west-1', '{"server_image": "ami-042df192e435e6fb3"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'ap-southeast-1', '{"server_image": "ami-01568ec8f5b6dc989"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'ap-southeast-2', '{"server_image": "ami-0c5b9ab59f97ceca7"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'ap-southeast-3', '{"server_image": "ami-01c86258ba749f015"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'ap-southeast-4', '{"server_image": "ami-0afd313fa12d9bcf0"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'us-east-1', '{"server_image": "ami-063fb82b183efe67d"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('aws', 'us-east-2', '{"server_image": "ami-0dc168b827060282d"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-09'), + ('gcp', 'all', '{"server_image": "projects/ubuntu-os-cloud/global/images/family/ubuntu-2404-lts-amd64"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-12'), + ('azure', 'all', '{"azure_vm_image_offer": "ubuntu-24_04-lts", "azure_vm_image_publisher": "Canonical", "azure_vm_image_sku": "server", "azure_vm_image_version": "latest"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-12'), + ('digitalocean', 'all', '{"server_image": "ubuntu-24-04-x64"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-12'), + ('hetzner', 'all', '{"server_image": "ubuntu-24.04"}', 'amd64', 'Ubuntu', '24.04 LTS', '2024-08-12'); + +ALTER TABLE ONLY public.cloud_images + ADD CONSTRAINT cloud_images_pkey PRIMARY KEY (cloud_provider, image); + +ALTER TABLE ONLY public.cloud_images + ADD CONSTRAINT cloud_images_cloud_provider_fkey FOREIGN KEY (cloud_provider) REFERENCES public.cloud_providers(provider_name); + +CREATE TRIGGER handle_updated_at BEFORE UPDATE ON public.cloud_images + FOR EACH ROW EXECUTE FUNCTION extensions.moddatetime (updated_at); + + +-- Projects +CREATE TABLE public.projects ( + project_id bigserial PRIMARY KEY, + project_name varchar(50) NOT NULL UNIQUE, + project_description varchar(150), + created_at timestamp DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp +); + +COMMENT ON TABLE public.projects IS 'Table containing information about projects'; +COMMENT ON COLUMN public.projects.project_name IS 'The name of the project'; +COMMENT ON COLUMN public.projects.project_description IS 'A description of the project'; +COMMENT ON COLUMN public.projects.created_at IS 'The timestamp when the project was created'; +COMMENT ON COLUMN public.projects.updated_at IS 'The timestamp when the project was last updated'; + +CREATE TRIGGER handle_updated_at BEFORE UPDATE ON public.projects + FOR EACH ROW EXECUTE FUNCTION extensions.moddatetime (updated_at); + +INSERT INTO public.projects (project_name) VALUES ('default'); + + +-- Environments +CREATE TABLE public.environments ( + environment_id bigserial PRIMARY KEY, + environment_name varchar(20) NOT NULL, + environment_description text, + created_at timestamp DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp +); + +COMMENT ON TABLE public.environments IS 'Table containing information about environments'; +COMMENT ON COLUMN public.environments.environment_name IS 'The name of the environment'; +COMMENT ON COLUMN public.environments.environment_description IS 'A description of the environment'; +COMMENT ON COLUMN public.environments.created_at IS 'The timestamp when the environment was created'; +COMMENT ON COLUMN public.environments.updated_at IS 'The timestamp when the environment was last updated'; + +CREATE TRIGGER handle_updated_at BEFORE UPDATE ON public.environments + FOR EACH ROW EXECUTE FUNCTION extensions.moddatetime (updated_at); + +CREATE INDEX environments_name_idx ON public.environments (environment_name); + +INSERT INTO public.environments (environment_name) VALUES ('production'); +INSERT INTO public.environments (environment_name) VALUES ('staging'); +INSERT INTO public.environments (environment_name) VALUES ('test'); +INSERT INTO public.environments (environment_name) VALUES ('dev'); +INSERT INTO public.environments (environment_name) VALUES ('benchmarking'); + + +-- Secrets +CREATE TABLE public.secrets ( + secret_id bigserial PRIMARY KEY, + project_id bigint REFERENCES public.projects(project_id), + secret_type text NOT NULL, + secret_name text NOT NULL UNIQUE, + secret_value bytea NOT NULL, -- Encrypted data + created_at timestamp DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp +); + +COMMENT ON TABLE public.secrets IS 'Table containing secrets for accessing cloud providers and servers'; +COMMENT ON COLUMN public.secrets.project_id IS 'The ID of the project to which the secret belongs'; +COMMENT ON COLUMN public.secrets.secret_type IS 'The type of the secret (e.g., cloud_secret, ssh_key, password)'; +COMMENT ON COLUMN public.secrets.secret_name IS 'The name of the secret'; +COMMENT ON COLUMN public.secrets.secret_value IS 'The encrypted value of the secret'; +COMMENT ON COLUMN public.secrets.created_at IS 'The timestamp when the secret was created'; +COMMENT ON COLUMN public.secrets.updated_at IS 'The timestamp when the secret was last updated'; + +CREATE TRIGGER handle_updated_at BEFORE UPDATE ON public.secrets + FOR EACH ROW EXECUTE FUNCTION extensions.moddatetime (updated_at); + +CREATE INDEX secrets_type_name_idx ON public.secrets (secret_type, secret_name); +CREATE INDEX secrets_id_project_idx ON public.secrets (secret_id, project_id); +CREATE INDEX secrets_project_idx ON public.secrets (project_id); + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION add_secret(p_project_id bigint, p_secret_type text, p_secret_name text, p_secret_value json, p_encryption_key text) +RETURNS bigint AS $$ +DECLARE + v_inserted_secret_id bigint; +BEGIN + INSERT INTO public.secrets (project_id, secret_type, secret_name, secret_value) + VALUES (p_project_id, p_secret_type, p_secret_name, extensions.pgp_sym_encrypt(p_secret_value::text, p_encryption_key, 'cipher-algo=aes256')) + RETURNING secret_id INTO v_inserted_secret_id; + + RETURN v_inserted_secret_id; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION update_secret( + p_secret_id bigint, + p_secret_type text DEFAULT NULL, + p_secret_name text DEFAULT NULL, + p_secret_value json DEFAULT NULL, + p_encryption_key text DEFAULT NULL +) +RETURNS TABLE ( + project_id bigint, + secret_id bigint, + secret_type text, + secret_name text, + created_at timestamp, + updated_at timestamp, + used boolean, + used_by_clusters text, + used_by_servers text +) AS $$ +BEGIN + IF p_secret_value IS NOT NULL AND p_encryption_key IS NULL THEN + RAISE EXCEPTION 'Encryption key must be provided when updating secret value'; + END IF; + + UPDATE public.secrets + SET + secret_name = COALESCE(p_secret_name, public.secrets.secret_name), + secret_type = COALESCE(p_secret_type, public.secrets.secret_type), + secret_value = CASE + WHEN p_secret_value IS NOT NULL THEN extensions.pgp_sym_encrypt(p_secret_value::text, p_encryption_key, 'cipher-algo=aes256') + ELSE public.secrets.secret_value + END + WHERE public.secrets.secret_id = p_secret_id; + + RETURN QUERY + SELECT + s.project_id, + s.secret_id, + s.secret_type, + s.secret_name, + s.created_at, + s.updated_at, + s.used, + s.used_by_clusters, + s.used_by_servers + FROM + public.v_secrets_list s + WHERE s.secret_id = p_secret_id; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION get_secret(p_secret_id bigint, p_encryption_key text) +RETURNS json AS $$ +DECLARE + decrypted_value json; +BEGIN + SELECT extensions.pgp_sym_decrypt(secret_value, p_encryption_key)::json + INTO decrypted_value + FROM public.secrets + WHERE secret_id = p_secret_id; + + RETURN decrypted_value; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +-- An example of using a function to insert a secret (value in JSON format) +-- SELECT add_secret(, 'ssh_key', '', '{"private_key": ""}', ''); +-- SELECT add_secret(, 'password', '', '{"username": "", "password": ""}', ''); +-- SELECT add_secret(, 'aws', '', '{"AWS_ACCESS_KEY_ID": "", "AWS_SECRET_ACCESS_KEY": ""}', ''); + +-- An example of using the function to update a secret +-- SELECT update_secret(, '', '', '', ''); + +-- An example of using a function to get a secret +-- SELECT get_secret(, ''); + + +-- Clusters +CREATE TABLE public.clusters ( + cluster_id bigserial PRIMARY KEY, + project_id bigint REFERENCES public.projects(project_id), + environment_id bigint REFERENCES public.environments(environment_id), + secret_id bigint REFERENCES public.secrets(secret_id), + cluster_name text NOT NULL UNIQUE, + cluster_status text DEFAULT 'deploying', + cluster_description text, + cluster_location text, + connection_info jsonb, + extra_vars jsonb, + inventory jsonb, + server_count integer DEFAULT 0, + postgres_version integer, + created_at timestamp DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp, + deleted_at timestamp, + flags integer DEFAULT 0 +); + +COMMENT ON TABLE public.clusters IS 'Table containing information about Postgres clusters'; +COMMENT ON COLUMN public.clusters.project_id IS 'The ID of the project to which the cluster belongs'; +COMMENT ON COLUMN public.clusters.environment_id IS 'The environment in which the cluster is deployed (e.g., production, development, etc)'; +COMMENT ON COLUMN public.clusters.cluster_name IS 'The name of the cluster (it must be unique)'; +COMMENT ON COLUMN public.clusters.cluster_status IS 'The status of the cluster (e.q., deploying, failed, healthy, unhealthy, degraded)'; +COMMENT ON COLUMN public.clusters.cluster_description IS 'A description of the cluster (optional)'; +COMMENT ON COLUMN public.clusters.connection_info IS 'The cluster connection info'; +COMMENT ON COLUMN public.clusters.extra_vars IS 'Extra variables for Ansible specific to this cluster'; +COMMENT ON COLUMN public.clusters.inventory IS 'The Ansible inventory for this cluster'; +COMMENT ON COLUMN public.clusters.cluster_location IS 'The region/datacenter where the cluster is located'; +COMMENT ON COLUMN public.clusters.server_count IS 'The number of servers associated with the cluster'; +COMMENT ON COLUMN public.clusters.postgres_version IS 'The Postgres major version'; +COMMENT ON COLUMN public.clusters.secret_id IS 'The ID of the secret for accessing the cloud provider'; +COMMENT ON COLUMN public.clusters.created_at IS 'The timestamp when the cluster was created'; +COMMENT ON COLUMN public.clusters.updated_at IS 'The timestamp when the cluster was last updated'; +COMMENT ON COLUMN public.clusters.deleted_at IS 'The timestamp when the cluster was (soft) deleted'; +COMMENT ON COLUMN public.clusters.flags IS 'Bitmask field for storing various status flags related to the cluster'; + +CREATE TRIGGER handle_updated_at BEFORE UPDATE ON public.clusters + FOR EACH ROW EXECUTE FUNCTION extensions.moddatetime (updated_at); + +CREATE INDEX clusters_id_project_id_idx ON public.clusters (cluster_id, project_id); +CREATE INDEX clusters_project_idx ON public.clusters (project_id); +CREATE INDEX clusters_environment_idx ON public.clusters (environment_id); +CREATE INDEX clusters_name_idx ON public.clusters (cluster_name); +CREATE INDEX clusters_secret_id_idx ON public.clusters (secret_id); + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION get_cluster_name() RETURNS text AS $$ +DECLARE + new_name text; + counter int := 1; +BEGIN + LOOP + new_name := 'postgres-cluster-' || to_char(counter, 'FM00'); + -- Check if such a cluster name already exists + IF NOT EXISTS (SELECT 1 FROM public.clusters WHERE cluster_name = new_name) THEN + RETURN new_name; + END IF; + counter := counter + 1; + END LOOP; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +-- Servers +CREATE TABLE public.servers ( + server_id bigserial PRIMARY KEY, + cluster_id bigint REFERENCES public.clusters(cluster_id), + server_name text NOT NULL, + server_location text, + server_role text DEFAULT 'N/A', + server_status text DEFAULT 'N/A', + ip_address inet NOT NULL, + timeline bigint, + lag bigint, + tags jsonb, + pending_restart boolean DEFAULT false, + created_at timestamp DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp +); + +COMMENT ON TABLE public.servers IS 'Table containing information about servers within a Postgres cluster'; +COMMENT ON COLUMN public.servers.cluster_id IS 'The ID of the cluster to which the server belongs'; +COMMENT ON COLUMN public.servers.server_name IS 'The name of the server'; +COMMENT ON COLUMN public.servers.server_location IS 'The region/datacenter where the server is located'; +COMMENT ON COLUMN public.servers.server_role IS 'The role of the server (e.g., primary, replica)'; +COMMENT ON COLUMN public.servers.server_status IS 'The current status of the server'; +COMMENT ON COLUMN public.servers.ip_address IS 'The IP address of the server'; +COMMENT ON COLUMN public.servers.timeline IS 'The timeline of the Postgres'; +COMMENT ON COLUMN public.servers.lag IS 'The lag in MB of the Postgres'; +COMMENT ON COLUMN public.servers.tags IS 'The tags associated with the server'; +COMMENT ON COLUMN public.servers.pending_restart IS 'Indicates whether a restart is pending for the Postgres'; +COMMENT ON COLUMN public.servers.created_at IS 'The timestamp when the server was created'; +COMMENT ON COLUMN public.servers.updated_at IS 'The timestamp when the server was last updated'; + +CREATE TRIGGER handle_updated_at BEFORE UPDATE ON public.servers + FOR EACH ROW EXECUTE FUNCTION extensions.moddatetime (updated_at); + +CREATE UNIQUE INDEX servers_cluster_id_ip_address_idx ON public.servers (cluster_id, ip_address); + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION update_server_count() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE public.clusters + SET server_count = ( + SELECT COUNT(*) + FROM public.servers + WHERE public.servers.cluster_id = NEW.cluster_id + ) + WHERE cluster_id = NEW.cluster_id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +-- Trigger to update server_count on changes in servers +CREATE TRIGGER update_server_count_trigger AFTER INSERT OR UPDATE OR DELETE ON public.servers + FOR EACH ROW EXECUTE FUNCTION update_server_count(); + + +-- Secrets view +CREATE VIEW public.v_secrets_list AS +SELECT + s.project_id, + s.secret_id, + s.secret_name, + s.secret_type, + s.created_at, + s.updated_at, + CASE + WHEN COUNT(c.secret_id) > 0 THEN true + ELSE false + END AS used, + COALESCE(string_agg(DISTINCT c.cluster_name, ', '), '') AS used_by_clusters +FROM + public.secrets s +LEFT JOIN LATERAL ( + SELECT cluster_name, secret_id + FROM public.clusters + WHERE secret_id = s.secret_id AND project_id = s.project_id +) c ON true +GROUP BY + s.project_id, s.secret_id, s.secret_name, s.secret_type, s.created_at, s.updated_at; + + +-- Extensions +CREATE TABLE public.extensions ( + extension_name text PRIMARY KEY, + extension_description varchar(150) NOT NULL, + extension_url text, + extension_image text, + postgres_min_version text, + postgres_max_version text, + contrib boolean NOT NULL +); + +COMMENT ON TABLE public.extensions IS 'Table containing available extensions for different Postgres versions'; +COMMENT ON COLUMN public.extensions.extension_name IS 'The name of the extension'; +COMMENT ON COLUMN public.extensions.extension_description IS 'The description of the extension'; +COMMENT ON COLUMN public.extensions.postgres_min_version IS 'The minimum Postgres version where the extension is available'; +COMMENT ON COLUMN public.extensions.postgres_max_version IS 'The maximum Postgres version where the extension is available'; +COMMENT ON COLUMN public.extensions.contrib IS 'Indicates if the extension is a contrib module or third-party extension'; + +-- The table stores information about Postgres extensions, including name, description, supported Postgres version range, +-- and whether the extension is a contrib module or third-party. +-- postgres_min_version and postgres_max_version define the range of Postgres versions supported by extensions. +-- If the postgres_max_version is NULL, it is assumed that the extension is still supported by new versions of Postgres. + +INSERT INTO public.extensions (extension_name, extension_description, postgres_min_version, postgres_max_version, extension_url, extension_image, contrib) VALUES + ('adminpack', 'administrative functions for PostgreSQL', NULL, NULL, NULL, NULL, true), + ('amcheck', 'functions for verifying relation integrity', NULL, NULL, NULL, NULL, true), + ('autoinc', 'functions for autoincrementing fields', NULL, NULL, NULL, NULL, true), + ('bloom', 'bloom access method - signature file based index', NULL, NULL, NULL, NULL, true), + ('btree_gin', 'support for indexing common datatypes in GIN', NULL, NULL, NULL, NULL, true), + ('btree_gist', 'support for indexing common datatypes in GiST', NULL, NULL, NULL, NULL, true), + ('chkpass', 'data type for auto-encrypted passwords', NULL, '10', NULL, NULL, true), + ('citext', 'data type for case-insensitive character strings', NULL, NULL, NULL, NULL, true), + ('cube', 'data type for multidimensional cubes', NULL, NULL, NULL, NULL, true), + ('dblink', 'connect to other PostgreSQL databases from within a database', NULL, NULL, NULL, NULL, true), + ('dict_int', 'text search dictionary template for integers', NULL, NULL, NULL, NULL, true), + ('dict_xsyn', 'text search dictionary template for extended synonym processing', NULL, NULL, NULL, NULL, true), + ('earthdistance', 'calculate great-circle distances on the surface of the Earth', NULL, NULL, NULL, NULL, true), + ('file_fdw', 'foreign-data wrapper for flat file access', NULL, NULL, NULL, NULL, true), + ('fuzzystrmatch', 'determine similarities and distance between strings', NULL, NULL, NULL, NULL, true), + ('hstore', 'data type for storing sets of (key, value) pairs', NULL, NULL, NULL, NULL, true), + ('insert_username', 'functions for tracking who changed a table', NULL, NULL, NULL, NULL, true), + ('intagg', 'integer aggregator and enumerator (obsolete)', NULL, NULL, NULL, NULL, true), + ('intarray', 'functions, operators, and index support for 1-D arrays of integers', NULL, NULL, NULL, NULL, true), + ('isn', 'data types for international product numbering standards', NULL, NULL, NULL, NULL, true), + ('lo', 'Large Object maintenance', NULL, NULL, NULL, NULL, true), + ('ltree', 'data type for hierarchical tree-like structures', NULL, NULL, NULL, NULL, true), + ('moddatetime', 'functions for tracking last modification time', NULL, NULL, NULL, NULL, true), + ('old_snapshot', 'utilities in support of old_snapshot_threshold', '14', NULL, NULL, NULL, true), + ('pageinspect', 'inspect the contents of database pages at a low level', NULL, NULL, NULL, NULL, true), + ('pg_buffercache', 'examine the shared buffer cache', NULL, NULL, NULL, NULL, true), + ('pg_freespacemap', 'examine the free space map (FSM)', NULL, NULL, NULL, NULL, true), + ('pg_prewarm', 'prewarm relation data', NULL, NULL, NULL, NULL, true), + ('pg_stat_statements', 'track planning and execution statistics of all SQL statements executed', NULL, NULL, NULL, NULL, true), + ('pg_surgery', 'extension to perform surgery on a damaged relation', '14', NULL, NULL, NULL, true), + ('pg_trgm', 'text similarity measurement and index searching based on trigrams', NULL, NULL, NULL, NULL, true), + ('pg_visibility', 'examine the visibility map (VM) and page-level visibility info', NULL, NULL, NULL, NULL, true), + ('pg_walinspect', 'functions to inspect contents of PostgreSQL Write-Ahead Log', '15', NULL, NULL, NULL, true), + ('pgcrypto', 'cryptographic functions', NULL, NULL, NULL, NULL, true), + ('pgrowlocks', 'show row-level locking information', NULL, NULL, NULL, NULL, true), + ('pgstattuple', 'show tuple-level statistics', NULL, NULL, NULL, NULL, true), + ('plpgsql', 'PL/pgSQL procedural language', NULL, NULL, NULL, NULL, true), + ('postgres_fdw', 'foreign-data wrapper for remote PostgreSQL servers', NULL, NULL, NULL, NULL, true), + ('refint', 'functions for implementing referential integrity (obsolete)', NULL, NULL, NULL, NULL, true), + ('seg', 'data type for representing line segments or floating-point intervals', NULL, NULL, NULL, NULL, true), + ('sslinfo', 'information about SSL certificates', NULL, NULL, NULL, NULL, true), + ('tablefunc', 'functions that manipulate whole tables, including crosstab', NULL, NULL, NULL, NULL, true), + ('tcn', 'Triggered change notifications', NULL, NULL, NULL, NULL, true), + ('timetravel', 'functions for implementing time travel', NULL, '11', NULL, NULL, true), + ('tsm_system_rows', 'TABLESAMPLE method which accepts number of rows as a limit', NULL, NULL, NULL, NULL, true), + ('tsm_system_time', 'TABLESAMPLE method which accepts time in milliseconds as a limit', NULL, NULL, NULL, NULL, true), + ('unaccent', 'text search dictionary that removes accents', NULL, NULL, NULL, NULL, true), + ('uuid-ossp', 'generate universally unique identifiers (UUIDs)', NULL, NULL, NULL, NULL, true), + ('xml2', 'XPath querying and XSLT', NULL, NULL, NULL, NULL, true), + -- Third-Party Extensions + ('citus', 'Citus is a PostgreSQL extension that transforms Postgres into a distributed database—so you can achieve high performance at any scale', 11, 16, 'https://github.com/citusdata/citus', 'citus.png', false), + ('pgaudit', 'The PostgreSQL Audit Extension provides detailed session and/or object audit logging via the standard PostgreSQL logging facility', 10, 16, 'https://github.com/pgaudit/pgaudit', 'pgaudit.png', false), + ('pg_cron', 'Job scheduler for PostgreSQL', 10, 16, 'https://github.com/citusdata/pg_cron', 'pg_cron.png', false), + ('pg_partman', 'pg_partman is an extension to create and manage both time-based and number-based table partition sets', 10, 16, 'https://github.com/pgpartman/pg_partman', 'pg_partman.png', false), + ('pg_repack', 'Reorganize tables in PostgreSQL databases with minimal locks', 10, 16, 'https://github.com/reorg/pg_repack', 'pg_repack.png', false), + ('pg_stat_kcache', 'Gather statistics about physical disk access and CPU consumption done by backends', 10, 16, 'https://github.com/powa-team/pg_stat_kcache', NULL, false), + ('pg_wait_sampling', 'Sampling based statistics of wait events', 10, 16, 'https://github.com/postgrespro/pg_wait_sampling', NULL, false), + ('pgvector', 'Open-source vector similarity search for Postgres (vector data type and ivfflat and hnsw access methods)', 11, 16, 'https://github.com/pgvector/pgvector', 'pgvector.png', false), + ('postgis', 'PostGIS extends the capabilities of the PostgreSQL relational database by adding support for storing, indexing, and querying geospatial data', 10, 16, 'https://postgis.net', 'postgis.png', false), + ('pgrouting', 'pgRouting extends the PostGIS / PostgreSQL geospatial database to provide geospatial routing functionality', 10, 16, 'https://pgrouting.org', 'pgrouting.png', false), + ('timescaledb', 'TimescaleDB is an open-source database designed to make SQL scalable for time-series data (Community Edition)', 12, 16, 'https://github.com/timescale/timescaledb', 'timescaledb.png', false); + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION get_extensions(p_postgres_version float, p_extension_type text DEFAULT 'all') +RETURNS json AS $$ +DECLARE + extensions json; +BEGIN + SELECT json_agg(row_to_json(e)) + INTO extensions + FROM ( + SELECT e.extension_name, e.extension_description, e.extension_url, e.extension_image, e.postgres_min_version, e.postgres_max_version, e.contrib + FROM public.extensions e + WHERE (e.postgres_min_version IS NULL OR e.postgres_min_version::float <= p_postgres_version) + AND (e.postgres_max_version IS NULL OR e.postgres_max_version::float >= p_postgres_version) + AND (p_extension_type = 'all' OR (p_extension_type = 'contrib' AND e.contrib = true) OR (p_extension_type = 'third_party' AND e.contrib = false)) + ORDER BY e.contrib, e.extension_image IS NULL, e.extension_name + ) e; + + RETURN extensions; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +-- An example of using a function to get a list of available extensions (all or 'contrib'/'third_party' only) +-- SELECT get_extensions(16); +-- SELECT get_extensions(16, 'contrib'); +-- SELECT get_extensions(16, 'third_party'); + + +-- Operations +CREATE TABLE public.operations ( + id bigserial, + project_id bigint REFERENCES public.projects(project_id), + cluster_id bigint REFERENCES public.clusters(cluster_id), + docker_code varchar(80) NOT NULL, + cid uuid, + operation_type text NOT NULL, + operation_status text NOT NULL CHECK (operation_status IN ('in_progress', 'success', 'failed')), + operation_log text, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone +); + +COMMENT ON TABLE public.operations IS 'Table containing logs of operations performed on clusters'; +COMMENT ON COLUMN public.operations.id IS 'The ID of the operation from the backend'; +COMMENT ON COLUMN public.clusters.project_id IS 'The ID of the project to which the operation belongs'; +COMMENT ON COLUMN public.operations.cluster_id IS 'The ID of the cluster related to the operation'; +COMMENT ON COLUMN public.operations.docker_code IS 'The CODE of the operation related to the docker daemon'; +COMMENT ON COLUMN public.operations.cid IS 'The correlation_id related to the operation'; +COMMENT ON COLUMN public.operations.operation_type IS 'The type of operation performed (e.g., deploy, edit, update, restart, delete, etc.)'; +COMMENT ON COLUMN public.operations.operation_status IS 'The status of the operation (in_progress, success, failed)'; +COMMENT ON COLUMN public.operations.operation_log IS 'The log details of the operation'; +COMMENT ON COLUMN public.operations.created_at IS 'The timestamp when the operation was created'; +COMMENT ON COLUMN public.operations.updated_at IS 'The timestamp when the operation was last updated'; + +CREATE TRIGGER handle_updated_at BEFORE UPDATE ON public.operations + FOR EACH ROW EXECUTE FUNCTION extensions.moddatetime (updated_at); + +-- add created_at as part of the primary key to be able to create a hypertable +ALTER TABLE ONLY public.operations + ADD CONSTRAINT operations_pkey PRIMARY KEY (created_at, id); + +CREATE INDEX operations_project_id_idx ON public.operations (project_id); +CREATE INDEX operations_cluster_id_idx ON public.operations (cluster_id); +CREATE INDEX operations_project_cluster_id_idx ON public.operations (project_id, cluster_id, created_at); +CREATE INDEX operations_project_cluster_id_operation_type_idx ON public.operations (project_id, cluster_id, operation_type, created_at); + +-- Check if the timescaledb extension is available and create hypertable if it is +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN + -- Convert the operations table to a hypertable + PERFORM create_hypertable('public.operations', 'created_at', chunk_time_interval => interval '1 month'); + + -- Check if the license allows compression policy + IF current_setting('timescaledb.license', true) = 'timescale' THEN + -- Enable compression on the operations hypertable, segmenting by project_id and cluster_id + ALTER TABLE public.operations SET ( + timescaledb.compress, + timescaledb.compress_orderby = 'created_at DESC, id DESC, operation_type, operation_status', + timescaledb.compress_segmentby = 'project_id, cluster_id' + ); + -- Compressing chunks older than one month + PERFORM add_compression_policy('public.operations', interval '1 month'); + ELSE + RAISE NOTICE 'Timescaledb license does not support compression policy. Skipping compression setup.'; + END IF; + ELSE + RAISE NOTICE 'Timescaledb extension is not available. Skipping hypertable and compression setup.'; + END IF; +END +$$; +-- +goose StatementEnd + +CREATE OR REPLACE VIEW public.v_operations AS +SELECT + op.project_id, + op.cluster_id, + op.id, + op.created_at AS "started", + op.updated_at AS "finished", + op.operation_type AS "type", + op.operation_status AS "status", + cl.cluster_name AS "cluster", + env.environment_name AS "environment" +FROM + public.operations op +JOIN + public.clusters cl ON op.cluster_id = cl.cluster_id +JOIN + public.projects pr ON op.project_id = pr.project_id +JOIN + public.environments env ON cl.environment_id = env.environment_id; + + +-- Postgres versions +CREATE TABLE public.postgres_versions ( + major_version integer PRIMARY KEY, + release_date date, + end_of_life date +); + +COMMENT ON TABLE public.postgres_versions IS 'Table containing the major PostgreSQL versions supported by the postgresql_cluster'; +COMMENT ON COLUMN public.postgres_versions.major_version IS 'The major version of PostgreSQL'; +COMMENT ON COLUMN public.postgres_versions.release_date IS 'The release date of the PostgreSQL version'; +COMMENT ON COLUMN public.postgres_versions.end_of_life IS 'The end of life date for the PostgreSQL version'; + +INSERT INTO public.postgres_versions (major_version, release_date, end_of_life) VALUES + (10, '2017-10-05', '2022-11-10'), + (11, '2018-10-18', '2023-11-09'), + (12, '2019-10-03', '2024-11-14'), + (13, '2020-09-24', '2025-11-13'), + (14, '2021-09-30', '2026-11-12'), + (15, '2022-10-13', '2027-11-11'), + (16, '2023-09-14', '2028-11-09'); + + +-- Settings +CREATE TABLE public.settings ( + id bigserial PRIMARY KEY, + setting_name text NOT NULL UNIQUE, + setting_value jsonb NOT NULL, + created_at timestamp DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp +); + +COMMENT ON TABLE public.settings IS 'Table containing configuration parameters, including console and other component settings'; +COMMENT ON COLUMN public.settings.setting_name IS 'The key of the setting'; +COMMENT ON COLUMN public.settings.setting_value IS 'The value of the setting'; +COMMENT ON COLUMN public.settings.created_at IS 'The timestamp when the setting was created'; +COMMENT ON COLUMN public.settings.updated_at IS 'The timestamp when the setting was last updated'; + +CREATE TRIGGER handle_updated_at BEFORE UPDATE ON public.settings + FOR EACH ROW EXECUTE FUNCTION extensions.moddatetime (updated_at); + +CREATE INDEX settings_name_idx ON public.settings (setting_name); + + +-- +goose Down + +-- Drop triggers +DROP TRIGGER update_server_count_trigger ON public.servers; +DROP TRIGGER handle_updated_at ON public.servers; +DROP TRIGGER handle_updated_at ON public.clusters; +DROP TRIGGER handle_updated_at ON public.environments; +DROP TRIGGER handle_updated_at ON public.projects; +DROP TRIGGER handle_updated_at ON public.secrets; +DROP TRIGGER handle_updated_at ON public.cloud_images; +DROP TRIGGER handle_updated_at ON public.cloud_volumes; +DROP TRIGGER handle_updated_at ON public.cloud_instances; +DROP TRIGGER handle_updated_at ON public.operations; + +-- Drop functions +DROP FUNCTION update_server_count; +DROP FUNCTION get_extensions; +DROP FUNCTION get_secret; +DROP FUNCTION add_secret; +DROP FUNCTION get_cluster_name; + +-- Drop views +DROP VIEW public.v_operations; +DROP VIEW public.v_secrets_list; + +-- Drop tables +DROP TABLE public.postgres_versions; +DROP TABLE public.operations; +DROP TABLE public.extensions; +DROP TABLE public.servers; +DROP TABLE public.clusters; +DROP TABLE public.secrets; +DROP TABLE public.environments; +DROP TABLE public.projects; +DROP TABLE public.cloud_images; +DROP TABLE public.cloud_volumes; +DROP TABLE public.cloud_instances; +DROP TABLE public.cloud_regions; +DROP TABLE public.cloud_providers; +DROP TABLE public.settings; diff --git a/console/db/pg_hba.conf b/console/db/pg_hba.conf new file mode 100644 index 000000000..4a85b1714 --- /dev/null +++ b/console/db/pg_hba.conf @@ -0,0 +1,3 @@ +local all all trust +host all all 127.0.0.1/32 trust +host all all 0.0.0.0/0 scram-sha-256 diff --git a/console/db/pg_start.sh b/console/db/pg_start.sh new file mode 100644 index 000000000..1f61e2a17 --- /dev/null +++ b/console/db/pg_start.sh @@ -0,0 +1,50 @@ +#!/bin/bash +set -e + +log() { + echo "$(date +'%Y-%m-%d %H:%M:%S') - $1" +} + +# Ensure the directory exists and has the correct permissions +mkdir -p ${PGDATA} ${PG_UNIX_SOCKET_DIR} /etc/postgresql/${POSTGRES_VERSION}/main +chown -R postgres:postgres ${PGDATA} ${PG_UNIX_SOCKET_DIR} /etc/postgresql/${POSTGRES_VERSION}/main + +# Create PGDATA if not exists +if [[ ! -d "${PGDATA}/base" ]]; then + log "Creating PostgreSQL data directory..." + su - postgres -c "pg_createcluster --locale en_US.UTF-8 ${POSTGRES_VERSION} main -d ${PGDATA} -- --data-checksums" + mv /etc/postgresql/${POSTGRES_VERSION}/main/postgresql.conf /etc/postgresql/${POSTGRES_VERSION}/main/postgresql.base.conf +fi + +# Check if the config file exists, if not, copy it +if [[ ! -f "/etc/postgresql/${POSTGRES_VERSION}/main/postgresql.conf" ]]; then + cp /var/tmp/postgresql.conf /etc/postgresql/${POSTGRES_VERSION}/main/postgresql.conf + cp /var/tmp/pg_hba.conf /etc/postgresql/${POSTGRES_VERSION}/main/pg_hba.conf + # Update data_directory in postgresql.conf + sed -i "s|^data_directory = .*|data_directory = '${PGDATA}'|" /etc/postgresql/${POSTGRES_VERSION}/main/postgresql.conf +fi + +# Start postgres +log "Starting PostgreSQL..." +su - postgres -c "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/postgres -D ${PGDATA} -k ${PG_UNIX_SOCKET_DIR} -p ${POSTGRES_PORT} -c config_file=/etc/postgresql/${POSTGRES_VERSION}/main/postgresql.conf" & + +for i in {1..300}; do + if pg_isready -h ${PG_UNIX_SOCKET_DIR} -p ${POSTGRES_PORT}; then + log "Postgres is ready!" + break + else + log "Postgres is not ready yet. Waiting..." + sleep 2 + fi +done + +# Reset postgres password +log "Resetting postgres password..." +psql -h ${PG_UNIX_SOCKET_DIR} -p ${POSTGRES_PORT} -U postgres -d postgres -c "ALTER USER postgres WITH PASSWORD '${POSTGRES_PASSWORD}';" + +# Create timescaledb extension (if not exists) +log "Creating TimescaleDB extension..." +psql -h ${PG_UNIX_SOCKET_DIR} -p ${POSTGRES_PORT} -U postgres -d postgres -c "CREATE EXTENSION IF NOT EXISTS timescaledb;" + +# Infinite sleep to allow restarting Postgres +/bin/bash -c "trap : TERM INT; sleep infinity & wait" diff --git a/console/db/postgresql.conf b/console/db/postgresql.conf new file mode 100644 index 000000000..f8d08d315 --- /dev/null +++ b/console/db/postgresql.conf @@ -0,0 +1,60 @@ +listen_addresses = '*' +port = 5432 +max_connections = 100 +superuser_reserved_connections = 5 +password_encryption = scram-sha-256 +max_locks_per_transaction = 512 +shared_preload_libraries = 'pg_stat_statements,timescaledb' +pg_stat_statements.track = all +timescaledb.max_background_workers = 4 +timescaledb.telemetry_level = off +huge_pages = try +shared_buffers = 256MB +work_mem = 64MB +maintenance_work_mem = 128MB +effective_cache_size = 1024MB +effective_io_concurrency = 200 +seq_page_cost = 1.0 +random_page_cost = 1.1 +default_statistics_target = 100 +autovacuum_max_workers = 5 +autovacuum_naptime = 1min +autovacuum_vacuum_scale_factor = 0.01 +autovacuum_analyze_scale_factor = 0.01 +autovacuum_vacuum_cost_limit = 500 +autovacuum_vacuum_cost_delay = 2 +max_files_per_process = 4096 +max_worker_processes = 16 +max_parallel_workers = 4 +max_parallel_workers_per_gather = 2 +max_parallel_maintenance_workers = 2 +synchronous_commit = off +archive_mode = on +archive_command = '/bin/true' +archive_timeout = 30min +wal_level = replica +wal_buffers = 32MB +wal_compression = on +max_wal_size = 2GB +checkpoint_completion_target = 0.9 +checkpoint_timeout = 15min +logging_collector = on +log_truncate_on_rotation = on +log_rotation_age = 1d +log_rotation_size = 0 +log_filename = 'postgresql-%a.log' +log_line_prefix = '%t [%p-%l] %r %q%u@%d ' +log_lock_waits = on +log_temp_files = 0 +log_checkpoints = on +track_activity_query_size = 2048 +track_io_timing = on +track_functions = all +track_activities = on +track_counts = on +tcp_keepalives_count = 10 +tcp_keepalives_idle = 300 +tcp_keepalives_interval = 30 +idle_in_transaction_session_timeout=10min +data_directory = '/var/lib/postgresql/16/main' +hba_file = '/etc/postgresql/16/main/pg_hba.conf' diff --git a/console/service/Dockerfile b/console/service/Dockerfile new file mode 100644 index 000000000..21143efe6 --- /dev/null +++ b/console/service/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.22.3-bookworm as builder +WORKDIR /go/src/pg-console + +COPY console/service/ . + +RUN make build_in_docker + +FROM debian:bookworm-slim +LABEL maintainer="Vitaliy Kukharik vitabaks@gmail.com" + +COPY --from=builder /go/src/pg-console/pg-console /usr/local/bin/ +COPY console/db/migrations /etc/db/migrations + +CMD ["/usr/local/bin/pg-console"] diff --git a/console/service/Makefile b/console/service/Makefile new file mode 100644 index 000000000..148ff4944 --- /dev/null +++ b/console/service/Makefile @@ -0,0 +1,31 @@ +ifndef GO_BIN +override GO_BIN = "pg-console" +endif + +APP = main.go + +swagger_install: + { \ + export my_dir=$$(pwd) ;\ + export dir=$$(mktemp -d) ;\ + retry_count=0 ;\ + max_retries=5 ;\ + until [ "$$retry_count" -ge "$$max_retries" ]; do \ + git clone https://github.com/go-swagger/go-swagger "$$dir" && break ;\ + retry_count=$$((retry_count+1)) ;\ + echo "Retry $$retry_count/$$max_retries" ;\ + sleep 1 ;\ + done ;\ + cd "$$dir" ;\ + go install ./cmd/swagger ;\ + cd "$$my_dir" ;\ + swagger version ;\ + } + +build: ## Build app + @go build -o $(GO_BIN) $(APP) + +swagger: + @swagger generate server --name PgConsole --spec api/swagger.yaml --principal interface{} --exclude-main + +build_in_docker: swagger_install swagger build diff --git a/console/service/README.md b/console/service/README.md new file mode 100644 index 000000000..18aa0dfe8 --- /dev/null +++ b/console/service/README.md @@ -0,0 +1,105 @@ +# PostgreSQL Cluster Console API service + +Server-side component for PostgreSQL Cluster Console. This REST service implements the API for UI integration. + +The project is written in `Go` and uses [Swagger](https://github.com/go-swagger/go-swagger) for server-side code generation. The server receives requests from the web to create and manage clusters. Under the hood, the server uses Docker to run `postgresql_cluster` image with Ansible playbooks for cluster deployment logic. + +## Build +Swagger specification is used for creating the server REST API. First, you need to install the Swagger tool to build the auto-generated Go files. +``` +export dir=$$(mktemp -d) +git clone https://github.com/go-swagger/go-swagger "$$dir" +cd "$$dir" +go install ./cmd/swagger +``` +Then, you need to generate the server-side files: +``` +swagger generate server --name DbConsole --spec api/swagger.yaml --principal interface{} --exclude-main +``` + +After that, you can build the server with the following command: +``` +go build -o pg-console main.go +``` + +The project also contains a Makefile with all commands, so you can simply run the following steps: +``` +make swagger_install +make swagger +make build +``` + +## Configuration +Server is configured via the environment. The following environment variables can be used: +``` +KEY TYPE DEFAULT REQUIRED DESCRIPTION +PG_CONSOLE_LOGGER_LEVEL String DEBUG Log level. Accepted values: [TRACE, DEBUG, INFO, WARN, ERROR, FATAL, PANIC] +PG_CONSOLE_HTTP_HOST String 0.0.0.0 Accepted host for connection. '0.0.0.0' for all hosts +PG_CONSOLE_HTTP_PORT Integer 8080 Listening port +PG_CONSOLE_HTTP_WRITETIMEOUT Duration 10s Maximum duration before timing out write of the response +PG_CONSOLE_HTTP_READTIMEOUT Duration 10s Maximum duration before timing out read of the request +PG_CONSOLE_HTTPS_ISUSED True or False false Flag for turn on/off https +PG_CONSOLE_HTTPS_HOST String 0.0.0.0 Accepted host for connection. '0.0.0.0' for all hosts +PG_CONSOLE_HTTPS_PORT Integer 8081 Listening port +PG_CONSOLE_HTTPS_CACERT String /etc/pg_console/cacert.pem The certificate to use for secure connections +PG_CONSOLE_HTTPS_SERVERCERT String /etc/pg_console/server-cert.pem The certificate authority file to be used with mutual tls auth +PG_CONSOLE_HTTPS_SERVERKEY String /etc/pg_console/server-key.pem The private key to use for secure connections +PG_CONSOLE_AUTHORIZATION_TOKEN String auth_token Authorization token for REST API +PG_CONSOLE_DB_HOST String localhost Database host +PG_CONSOLE_DB_PORT Unsigned Integer 5432 Database port +PG_CONSOLE_DB_DBNAME String postgres Database name +PG_CONSOLE_DB_USER String postgres Database user name +PG_CONSOLE_DB_PASSWORD String postgres-pass Database user password +PG_CONSOLE_DB_MAXCONNS Integer 10 MaxConns is the maximum size of the pool +PG_CONSOLE_DB_MAXCONNLIFETIME Duration 60s MaxConnLifetime is the duration since creation after which a connection will be automatically closed +PG_CONSOLE_DB_MAXCONNIDLETIME Duration 60s MaxConnIdleTime is the duration after which an idle connection will be automatically closed by the health check +PG_CONSOLE_DB_MIGRATIONDIR String /etc/db/migrations Path to directory with migration scripts +PG_CONSOLE_ENCRYPTIONKEY String super_secret Encryption key for secret storage +PG_CONSOLE_DOCKER_HOST String unix:///var/run/docker.sock Docker host +PG_CONSOLE_DOCKER_LOGDIR String /tmp/ansible Directory inside docker container for ansible json log +PG_CONSOLE_DOCKER_IMAGE String vitabaks/postgresql_cluster:2.0.0 Docker image for postgresql_cluster +PG_CONSOLE_LOGWATCHER_RUNEVERY Duration 1m LogWatcher run interval +PG_CONSOLE_LOGWATCHER_ANALYZEPAST Duration 48h LogWatcher gets operations to analyze which created_at > now() - AnalyzePast +PG_CONSOLE_CLUSTERWATCHER_RUNEVERY Duration 1m ClusterWatcher run interval +PG_CONSOLE_CLUSTERWATCHER_POOLSIZE Integer 4 Amount of async request from ClusterWatcher +``` + +Note: Be attention to use `TRACE` level of logging. With `TRACE` level some kind of secrets can be present in logs. + +## Project structure +``` +|-api - Swagger specification +|-internal - Folder with all internal logic +| |-configuration - Configuration +| |-controllers - REST functions and basic logic for handlers +| | |-cluster - REST API for cluster objects +| | |-dictionary - REST API for dictionary objects +| | |-environment - REST API for environment objects +| | |-operation - REST API for operation objects +| | |-project - REST API for project objects +| | |-secret - REST API for secret objects +| | |-setting - REST API for setting objects +| |-convert - Functions for converting DB model to REST model +| |-db - Basic DB functions +| |-service - Common logic for aggregating all server logic +| |-storage - DB logic +| |-watcher - Async watchers +| | |-log_collector.go - Collecting logs from running Docker containers +| | |-log_watcher.go - JSON container log parser +| | |-cluster_watcher.go - Collecting cluster statuses +| |-xdocker - Basic logic for Docker +|-middleware - Common REST middleware for the server +|-migrations - DB migration logic +|-pkg - Folder with common logic +| |-patroni - Client for Patroni integration +| |-tracer - Base structure for tracing +|-*models - Auto-generated files with REST models +|-*restapi - Auto-generated files with REST server +|-main.go - Entry point +``` + +## Secrets +The server handles different kinds of secrets, such as: + +* Cloud secrets used for cloud connections +* SSH keys and passwords for connection to own machine servers diff --git a/console/service/VERSION b/console/service/VERSION new file mode 100644 index 000000000..359a5b952 --- /dev/null +++ b/console/service/VERSION @@ -0,0 +1 @@ +2.0.0 \ No newline at end of file diff --git a/console/service/api/swagger.yaml b/console/service/api/swagger.yaml new file mode 100644 index 000000000..2566dcbbb --- /dev/null +++ b/console/service/api/swagger.yaml @@ -0,0 +1,1663 @@ +--- +swagger: '2.0' +info: + title: PG Console + description: API for PostgreSQL Cluster Console + version: 2.0.0 +host: localhost:8080 +schemes: + - http +produces: + - application/json +consumes: + - application/json +basePath: "/api/v1" + +paths: + /version: + get: + summary: Get version of API service + tags: + - system + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.Version" + + /external/deployments: + get: + summary: Get full info about available external deployments + tags: + - dictionary + parameters: + - name: offset + in: query + required: false + type: integer + - name: limit + in: query + required: false + type: integer + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.DeploymentsInfo" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + /database/extensions: + get: + summary: "Info about available database extensions" + tags: + - dictionary + parameters: + - name: offset + in: query + required: false + type: integer + - name: limit + in: query + required: false + type: integer + - name: extension_type + in: query + required: false + type: string + default: "all" + enum: + - "all" + - "contrib" + - "third_party" + - name: postgres_version + in: query + required: false + type: string + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.DatabaseExtensions" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + /environments: + get: + summary: "Get environments list" + tags: + - environment + parameters: + - name: limit + in: query + required: false + type: integer + - name: offset + in: query + required: false + type: integer + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.EnvironmentsList" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + post: + summary: "Create environment" + tags: + - environment + parameters: + - name: body + in: body + required: true + schema: + $ref: '#/definitions/Request.Environment' + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.Environment" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + /environments/{id}: + delete: + summary: "Delete environment" + tags: + - environment + parameters: + - name: id + in: path + required: true + type: integer + responses: + '204': + description: OK + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + /postgres_versions: + get: + summary: "Get supported postgres versions" + tags: + - dictionary + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.PostgresVersions" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + /settings: + post: + summary: "Create new setting" + tags: + - setting + parameters: + - name: body + in: body + required: true + schema: + $ref: '#/definitions/Request.CreateSetting' + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.Setting" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + get: + summary: "Get settings" + tags: + - setting + parameters: + - name: name + in: query + required: false + type: string + description: "Filter by name" + - name: offset + in: query + required: false + type: integer + - name: limit + in: query + required: false + type: integer + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.Settings" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + /settings/{name}: + patch: + summary: "Changed setting" + tags: + - setting + parameters: + - name: name + in: path + type: string + required: true + - name: body + in: body + required: true + schema: + $ref: '#/definitions/Request.ChangeSetting' + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.Setting" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + /clusters: + post: + summary: "Create new cluster" + tags: + - cluster + parameters: + - name: body + in: body + required: true + schema: + $ref: '#/definitions/Request.ClusterCreate' + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.ClusterCreate" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + get: + summary: "Get info about clusters" + tags: + - cluster + parameters: + - name: offset + in: query + required: false + type: integer + - name: limit + in: query + required: false + type: integer + - name: project_id + in: query + required: true + type: integer + - name: name + in: query + required: false + type: string + description: "Filter by name" + - name: status + type: string + in: query + required: false + description: "Filter by status" + - name: location + type: string + in: query + required: false + description: "Filter by location" + - name: environment + type: string + in: query + required: false + description: "Filter by environment" + - name: server_count + type: integer + in: query + required: false + description: "Filter by server_count" + - name: postgres_version + type: integer + in: query + required: false + description: "Filter by postgres_version" + - name: created_at_from + required: false + type: string + format: date-time + in: query + description: "Created at after this date" + - name: created_at_to + required: false + type: string + format: date-time + in: query + description: "Created at till this date" + - name: sort_by + in: query + required: false + type: string + description: "Sort by fields. Example: sort_by=id,-name,created_at,updated_at\n + Supported values:\n + - id\n + - name\n + - created_at\n + - updated_at\n + - environment\n + - project\n + - status\n + - location\n + - server_count\n + - postgres_version\n" + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.ClustersInfo" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + /clusters/default_name: + get: + summary: "Get cluster default name" + tags: + - cluster + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.ClusterDefaultName" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + /clusters/{id}: + get: + summary: "Get cluster info" + tags: + - cluster + parameters: + - name: id + in: path + required: true + type: integer + responses: + '200': + description: OK + schema: + $ref: "#/definitions/ClusterInfo" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + delete: + summary: "Delete cluster (from the console database)" + tags: + - cluster + parameters: + - name: id + in: path + required: true + type: integer + responses: + '204': + description: OK + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + /servers/{id}: + delete: + summary: "Delete server (from the console database)" + tags: + - cluster + parameters: + - name: id + in: path + required: true + type: integer + responses: + '204': + description: OK + headers: + x-cluster-id: + type: integer + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + /clusters/{id}/refresh: + post: + summary: "Refresh cluster info (from Patroni API)" + tags: + - cluster + parameters: + - name: id + in: path + required: true + type: integer + responses: + '200': + description: OK + schema: + $ref: "#/definitions/ClusterInfo" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + + # TODO: not implemented yet + /clusters/{id}/reinit: + post: + summary: "Reinit cluster" + deprecated: true + tags: + - cluster + parameters: + - name: body + in: body + required: true + schema: + $ref: '#/definitions/Request.ClusterReinit' + - name: id + in: path + required: true + type: integer + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.ClusterCreate" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + # TODO: not implemented yet + /clusters/{id}/reload: + post: + summary: "Reload cluster" + deprecated: true + tags: + - cluster + parameters: + - name: body + in: body + required: true + schema: + $ref: '#/definitions/Request.ClusterReload' + - name: id + in: path + required: true + type: integer + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.ClusterCreate" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + # TODO: not implemented yet + /clusters/{id}/restart: + post: + summary: "Restart cluster" + deprecated: true + tags: + - cluster + parameters: + - name: body + in: body + required: true + schema: + $ref: '#/definitions/Request.ClusterRestart' + - name: id + in: path + required: true + type: integer + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.ClusterCreate" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + # TODO: not implemented yet + /clusters/{id}/stop: + post: + summary: "Stop cluster" + deprecated: true + tags: + - cluster + parameters: + - name: body + in: body + required: true + schema: + $ref: '#/definitions/Request.ClusterStop' + - name: id + in: path + required: true + type: integer + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.ClusterCreate" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + # TODO: not implemented yet + /clusters/{id}/start: + post: + summary: "Start cluster" + deprecated: true + tags: + - cluster + parameters: + - name: body + in: body + required: true + schema: + $ref: '#/definitions/Request.ClusterStart' + - name: id + in: path + required: true + type: integer + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.ClusterCreate" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + # TODO: not implemented yet + /clusters/{id}/remove: + post: + summary: "Remove cluster" + deprecated: true + tags: + - cluster + parameters: + - name: body + in: body + required: true + schema: + $ref: '#/definitions/Request.ClusterRemove' + - name: id + in: path + required: true + type: integer + responses: + '204': + description: OK + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + /projects: + post: + summary: "Create new project" + tags: + - project + parameters: + - name: body + in: body + required: true + schema: + $ref: '#/definitions/Request.ProjectCreate' + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.Project" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + get: + summary: "Get projects list" + tags: + - project + parameters: + - name: limit + in: query + required: false + type: integer + - name: offset + in: query + required: false + type: integer + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.ProjectsList" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + /projects/{id}: + patch: + summary: "Change project" + tags: + - project + parameters: + - name: id + in: path + required: true + type: integer + - name: body + in: body + required: true + schema: + $ref: '#/definitions/Request.ProjectPatch' + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.Project" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + delete: + summary: "Delete project" + tags: + - project + parameters: + - name: id + in: path + required: true + type: integer + responses: + '204': + description: OK + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + /secrets: + post: + summary: "Create new secret" + tags: + - secret + parameters: + - name: body + in: body + required: true + schema: + $ref: '#/definitions/Request.SecretCreate' + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.SecretInfo" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + get: + summary: "Get secrets list" + tags: + - secret + parameters: + - name: limit + in: query + required: false + type: integer + - name: offset + in: query + required: false + type: integer + - name: project_id + in: query + required: true + type: integer + - name: name + in: query + required: false + type: string + description: "Filter by name" + - name: type + in: query + required: false + type: string + description: "Filter by type" + - name: sort_by + in: query + required: false + type: string + description: "Sort by fields. Example: sort_by=id,name,-type,created_at,updated_at" + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.SecretInfoList" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + /secrets/{id}: + patch: + summary: "Change secret" + tags: + - secret + parameters: + - name: id + in: path + required: true + type: integer + - name: body + in: body + required: true + schema: + $ref: '#/definitions/Request.SecretPatch' + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.SecretInfo" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + delete: + summary: "Delete secret" + tags: + - secret + parameters: + - name: id + in: path + required: true + type: integer + responses: + '204': + description: OK + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + /operations: + get: + summary: "Get operations list for current project" + tags: + - operation + parameters: + - name: project_id + in: query + required: true + type: integer + description: "Required parameter for filter" + - name: start_date + required: true + type: string + format: date-time + in: query + description: "Operations started after this date" + - name: end_date + required: true + type: string + format: date-time + in: query + description: "Operations started till this date" + - name: cluster_name + in: query + required: false + type: string + description: "Filter by cluster_name" + - name: type + in: query + required: false + type: string + description: "Filter by type" + - name: status + in: query + required: false + type: string + description: "Filter by status" + - name: environment + in: query + required: false + type: string + description: "Filter by environment" + - name: sort_by + in: query + required: false + type: string + description: "Sort by fields. Example: sort_by=cluster_name,-type,status,id,created_at,updated_at\n + Supported valuese:\n + - id\n + - cluster_name\n + - type\n + - status\n + - started_at\n + - updated_at\n + - cluster\n + - environment\n" + - name: limit + in: query + required: false + type: integer + - name: offset + in: query + required: false + type: integer + responses: + '200': + description: OK + schema: + $ref: "#/definitions/Response.OperationsList" + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + + /operations/{id}/log: + get: + summary: "Get operation log by operation_id" + tags: + - operation + consumes: + - plain/text + parameters: + - name: id + in: path + required: true + type: integer + description: "Operation id" + responses: + '200': + description: OK + schema: + type: string + headers: + content-type: + type: string + x-log-completed: + type: boolean + '400': + description: Error + schema: + $ref: "#/definitions/Response.Error" + +definitions: + Response.Version: + title: Version response + type: object + properties: + version: + type: string + example: v1.0.0 + + Response.Error: + title: Error object + type: object + properties: + code: + type: integer + title: + type: string + description: + type: string + + Meta.Pagination: + title: Pagination info for list requests + type: object + properties: + offset: + type: integer + x-nullable: true + limit: + type: integer + x-nullable: true + count: + type: integer + x-nullable: true + + Response.DeploymentsInfo: + title: Deployments info + type: object + properties: + data: + type: array + items: + $ref: "#/definitions/Response.DeploymentInfo" + meta: + $ref: '#/definitions/Meta.Pagination' + + Response.DeploymentInfo: + description: Deployment info + type: object + properties: + code: + type: string + example: "aws" + description: + type: string + example: "Amazon web services" + avatar_url: + type: string + cloud_regions: + description: "List of available regions for current deployment" + type: array + items: + $ref: '#/definitions/DeploymentInfo.CloudRegion' + instance_types: + description: "Lists of available instance types" + type: object + properties: + small: + type: array + x-nullable: true + items: + $ref: '#/definitions/Deployment.InstanceType' + medium: + type: array + items: + $ref: '#/definitions/Deployment.InstanceType' + large: + type: array + items: + $ref: '#/definitions/Deployment.InstanceType' + volumes: + type: array + description: "Hardware disks info" + items: + type: object + properties: + volume_type: + type: string + description: "Volume type" + example: "gp3" + volume_description: + type: string + description: "Volume description" + example: "General purpose SSD disk" + min_size: + type: integer + description: "Sets in GB" + example: 10 + max_size: + type: integer + description: "Sets in GB" + example: 256 + price_monthly: + type: number + description: "Price for disk by months" + example: 0.1 + currency: + type: string + description: "Price currency" + example: "$" + is_default: + type: boolean + x-nullable: true + description: "Default volume" + example: false + + DeploymentInfo.CloudRegion: + type: object + properties: + code: + type: string + description: "unique parameter for DB" + example: "north_america" + name: + type: string + description: "Field for web" + example: "North America" + datacenters: + type: array + description: "List of datacenters for this region" + items: + type: object + properties: + code: + type: string + example: "ca-central-1" + location: + type: string + example: "Canada (central)" + cloud_image: + $ref: '#/definitions/Deployment.CloudImage' + + Deployment.CloudImage: + type: object + properties: + image: + type: object + example: '{"server_image": "ami-078b3985bbc361448"}' + arch: + type: string + example: "amd64" + os_name: + type: string + example: "Ubuntu" + os_version: + type: string + example: "22.04 LTS" + updated_at: + type: string + format: datetime + + Deployment.InstanceType: + type: object + properties: + code: + type: string + example: "m5.2xlarge" + cpu: + type: integer + example: 8 + ram: + type: integer + example: 256 + price_hourly: + type: number + description: "Price for 1 instance by hour" + example: 0.01 + price_monthly: + type: number + description: "Price for 1 instance by month" + example: 1.2 + currency: + type: string + description: "Price currency" + example: "$" + + Response.DatabaseExtensions: + type: object + properties: + data: + type: array + items: + $ref: '#/definitions/Response.DatabaseExtension' + meta: + $ref: '#/definitions/Meta.Pagination' + + Response.DatabaseExtension: + type: object + description: "Info about database extension" + properties: + name: + type: string + example: "Citus" + description: + type: string + x-nullable: true + example: "Citus is PostgreSQL extension that transforms..." + url: + type: string + x-nullable: true + example: "https://github.com/citusdata/citus" + image: + type: string + x-nullable: true + example: "citus.png" + postgres_min_version: + type: string + x-nullable: true + example: "11" + postgres_max_version: + type: string + x-nullable: true + example: "16" + contrib: + type: boolean + example: false + + Request.ClusterCreate: + type: object + description: "Request struct for cluster creation" + properties: + name: + type: string + example: "drm-prod-pgcluster" + description: + type: string + description: "Info about cluster" + auth_info: + type: object + description: "Info for deployment system authorization" + properties: + secret_id: + type: integer + example: 1 + project_id: + type: integer + description: "Project for new cluster" + environment_id: + type: integer + description: "Project environment" + envs: + type: array + items: + type: string + extra_vars: + type: array + items: + type: string + + Response.ClusterDefaultName: + type: object + description: "Response struct for cluster default name" + properties: + name: + type: string + example: "postgres-cluster-01" + + Response.ClusterCreate: + type: object + description: "Response struct for cluster creation" + properties: + cluster_id: + type: integer + description: "unique code for cluster" + operation_id: + type: integer + description: "operation id" + + Response.ClusterLogs: + type: object + description: "Logs for cluster" + properties: + logs: + type: string + description: "all available logs" + + Response.ClustersInfo: + type: object + properties: + data: + type: array + items: + $ref: '#/definitions/ClusterInfo' + meta: + $ref: '#/definitions/Meta.Pagination' + + ClusterInfo: + type: object + description: "Cluster info" + properties: + id: + type: integer + name: + type: string + example: "drm-prod-pgcluster" + description: + type: string + status: + type: string + example: "healthy" + creation_time: + type: string + format: date-time + example: "16.10.2023T11:20:00Z" + environment: + type: string + example: "production" + servers: + type: array + items: + $ref: "#/definitions/ClusterInfo.Instance" + postgres_version: + type: integer + format: int32 + example: 15 + cluster_location: + type: string + description: "Code of location" + example: "eu-north-1" + project_name: + type: string + description: "Project for cluster" + connection_info: + type: object + + ClusterInfo.AdditionalSettings: + type: object + description: "Additional settings for cluster" + properties: + connection_info: + type: object + + ClusterInfo.Instance: + type: object + description: "Instance info for current cluster" + properties: + id: + type: integer + name: + type: string + example: "pgnode1" + ip: + type: string + example: "10.128.64.141" + status: + type: string + role: + type: string + example: "leader" + timeline: + type: integer + format: int64 + example: 1 + x-nullable: true + lag: + type: integer + format: int64 + example: 0 + x-nullable: true + tags: + type: object + pending_restart: + type: boolean + example: false + x-nullable: true + + Request.ClusterReinit: + type: object + description: "Reinit cluster" + + Request.ClusterReload: + type: object + description: "Reload cluster" + + Request.ClusterRestart: + type: object + description: "Restart cluster" + + Request.ClusterStop: + type: object + description: "Stop cluster" + + Request.ClusterStart: + type: object + description: "Start cluster" + + Request.ClusterRemove: + type: object + description: "Remove cluster" + + Request.ProjectCreate: + type: object + properties: + name: + type: string + example: "default" + description: + type: string + example: "Default project" + + Request.ProjectPatch: + type: object + properties: + name: + type: string + x-nullable: true + description: + type: string + x-nullable: true + + Response.Project: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + x-nullable: true + created_at: + type: string + format: date-time + example: "16.10.2023T11:20:00Z" + updated_at: + type: string + format: date-time + x-nullable: true + example: "16.10.2023T11:20:00Z" + + Response.ProjectsList: + type: object + properties: + data: + type: array + items: + $ref: '#/definitions/Response.Project' + meta: + type: object + $ref: '#/definitions/Meta.Pagination' + + Request.SecretCreate: + type: object + properties: + project_id: + type: integer + example: 1 + name: + type: string + example: "aws key" + type: + $ref: '#/definitions/Secret.Type' + value: + type: object + $ref: '#/definitions/Request.SecretValue' + + Secret.Type: + type: string + enum: + - "aws" + - "gcp" + - "hetzner" + - "ssh_key" + - "digitalocean" + - "password" + - "azure" + + Request.SecretValue: + type: object + properties: + aws: + type: object + $ref: '#/definitions/Request.SecretValue.Aws' + x-nullable: true + gcp: + type: object + $ref: '#/definitions/Request.SecretValue.Gcp' + x-nullable: true + hetzner: + type: object + $ref: '#/definitions/Request.SecretValue.Hetzner' + x-nullable: true + ssh_key: + type: object + $ref: '#/definitions/Request.SecretValue.SshKey' + x-nullable: true + digitalocean: + type: object + $ref: '#/definitions/Request.SecretValue.DigitalOcean' + x-nullable: true + password: + type: object + $ref: '#/definitions/Request.SecretValue.Password' + x-nullable: true + azure: + type: object + $ref: '#/definitions/Request.SecretValue.Azure' + x-nullable: true + + Request.SecretValue.Aws: + type: object + properties: + AWS_ACCESS_KEY_ID: + type: string + AWS_SECRET_ACCESS_KEY: + type: string + + Request.SecretValue.Gcp: + type: object + properties: + GCP_SERVICE_ACCOUNT_CONTENTS: + type: string + + Request.SecretValue.Hetzner: + type: object + properties: + HCLOUD_API_TOKEN: + type: string + + Request.SecretValue.SshKey: + type: object + properties: + SSH_PRIVATE_KEY: + type: string + + Request.SecretValue.DigitalOcean: + type: object + properties: + DO_API_TOKEN: + type: string + + Request.SecretValue.Password: + type: object + properties: + USERNAME: + type: string + PASSWORD: + type: string + + Request.SecretValue.Azure: + type: object + properties: + AZURE_SUBSCRIPTION_ID: + type: string + AZURE_CLIENT_ID: + type: string + AZURE_SECRET: + type: string + AZURE_TENANT: + type: string + + Request.SecretPatch: + type: object + properties: + name: + type: string + example: "aws key" + x-nullable: true + type: + type: string + example: "aws" + x-nullable: true + value: + type: string + example: "c2VjcmV0" + description: "Secret value in base64" + x-nullable: true + + Response.SecretInfo: + type: object + properties: + id: + type: integer + example: 1 + project_id: + type: integer + example: 1 + name: + type: string + example: "aws key" + type: + $ref: '#/definitions/Secret.Type' + created_at: + type: string + format: date-time + example: "16.10.2023T11:20:00Z" + updated_at: + type: string + format: date-time + x-nullable: true + example: "16.10.2023T11:20:00Z" + is_used: + type: boolean + example: "true" + used_by_clusters: + type: string + x-nullable: true + example: "mds-prod, drm-prod" + + Response.SecretInfoList: + type: object + properties: + data: + type: array + items: + $ref: '#/definitions/Response.SecretInfo' + meta: + type: object + $ref: '#/definitions/Meta.Pagination' + + Response.Operation: + type: object + properties: + id: + type: integer + example: 1 + cluster_name: + type: string + example: "drm-prod-cluster" + started: + type: string + format: date-time + example: "16.10.2023T11:20:00Z" + finished: + type: string + format: date-time + example: "16.10.2023T11:20:00Z" + x-nullable: true + type: + type: string + example: "deploy" + status: + type: string + example: "success" + environment: + type: string + example: "production" + + Request.Environment: + type: object + properties: + name: + type: string + example: "production" + description: + type: string + example: "environment for production" + + + Response.OperationsList: + type: object + properties: + data: + type: array + items: + $ref: '#/definitions/Response.Operation' + meta: + type: object + $ref: '#/definitions/Meta.Pagination' + + Response.Environment: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + example: "production" + description: + type: string + x-nullable: true + example: "environment for production" + created_at: + type: string + format: date-time + example: "16.10.2023T11:20:00Z" + updated_at: + type: string + format: date-time + x-nullable: true + example: "16.10.2023T11:20:00Z" + + Response.EnvironmentsList: + type: object + properties: + data: + type: array + items: + $ref: '#/definitions/Response.Environment' + meta: + type: object + $ref: '#/definitions/Meta.Pagination' + + Response.PostgresVersions: + type: object + properties: + data: + type: array + items: + $ref: '#/definitions/Response.PostgresVersion' + + Response.PostgresVersion: + type: object + properties: + major_version: + type: integer + example: 10 + release_date: + type: string + format: date + example: "2017-10-05" + end_of_life: + type: string + format: date + example: "2022-11-10" + + Request.CreateSetting: + type: object + description: "Create new setting" + properties: + name: + type: string + value: + type: object + + Request.ChangeSetting: + type: object + description: "Change setting" + properties: + value: + type: object + x-nullable: true + + Response.Setting: + type: object + description: "Setting" + properties: + id: + type: integer + name: + type: string + value: + type: object + created_at: + type: string + format: datetime + updated_at: + type: string + format: datetime + x-nullable: true + + Response.Settings: + type: object + description: "List of settings" + properties: + data: + type: array + items: + $ref: '#/definitions/Response.Setting' + mete: + type: object + $ref: '#/definitions/Meta.Pagination' diff --git a/console/service/env.sh b/console/service/env.sh new file mode 100755 index 000000000..ff8041393 --- /dev/null +++ b/console/service/env.sh @@ -0,0 +1,7 @@ +export PG_CONSOLE_DB_MIGRATIONDIR='./db/migrations' +export PG_CONSOLE_LOGGER_LEVEL=TRACE +export PG_CONSOLE_DB_DBNAME=db_console +export PG_CONSOLE_DOCKER_LOGDIR='/home/nikolay.gurban/log_dir' +export PG_CONSOLE_DB_PASSWORD=postgres +export PG_CONSOLE_LOGWATCHER_RUNEVERY=10m +export PG_CONSOLE_CLUSTERWATCHER_RUNEVERY=10m \ No newline at end of file diff --git a/console/service/go.mod b/console/service/go.mod new file mode 100644 index 000000000..496fea8f1 --- /dev/null +++ b/console/service/go.mod @@ -0,0 +1,74 @@ +module postgresql-cluster-console + +go 1.21 + +require ( + github.com/docker/docker v26.1.2+incompatible + github.com/docker/go-connections v0.5.0 + github.com/gdex-lab/go-render v1.0.1 + github.com/go-openapi/errors v0.22.0 + github.com/go-openapi/loads v0.22.0 + github.com/go-openapi/runtime v0.28.0 + github.com/go-openapi/spec v0.21.0 + github.com/go-openapi/strfmt v0.23.0 + github.com/go-openapi/swag v0.23.0 + github.com/go-openapi/validate v0.24.0 + github.com/google/uuid v1.6.0 + github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e + github.com/jackc/pgx/v5 v5.5.5 + github.com/jessevdk/go-flags v1.5.0 + github.com/kelseyhightower/envconfig v1.4.0 + github.com/mitchellh/mapstructure v1.5.0 + github.com/pressly/goose/v3 v3.20.0 + github.com/rs/zerolog v1.32.0 + github.com/segmentio/asm v1.2.0 + go.openly.dev/pointy v1.3.0 + golang.org/x/net v0.25.0 + golang.org/x/sync v0.7.0 + gotest.tools/v3 v3.5.1 +) + +require ( + github.com/Microsoft/go-winio v0.4.14 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/sethvargo/go-retry v0.2.4 // indirect + go.mongodb.org/mongo-driver v1.14.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect + go.opentelemetry.io/otel v1.26.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.26.0 // indirect + go.opentelemetry.io/otel/metric v1.26.0 // indirect + go.opentelemetry.io/otel/sdk v1.26.0 // indirect + go.opentelemetry.io/otel/trace v1.26.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + golang.org/x/time v0.5.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/console/service/go.sum b/console/service/go.sum new file mode 100644 index 000000000..1bf431c28 --- /dev/null +++ b/console/service/go.sum @@ -0,0 +1,238 @@ +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= +github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v26.1.2+incompatible h1:UVX5ZOrrfTGZZYEP+ZDq3Xn9PdHNXaSYMFPDumMqG2k= +github.com/docker/docker v26.1.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gdex-lab/go-render v1.0.1 h1:xk5dn5b0vAUntzcLD57sVpw6crIjkBaVHJxDd/KN2Mc= +github.com/gdex-lab/go-render v1.0.1/go.mod h1:0Cgpq7v7yfmmvplBne9VgJl97YlpT8B9RlgcjdF+Uxc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= +github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= +github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= +github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco= +github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs= +github.com/go-openapi/runtime v0.28.0 h1:gpPPmWSNGo214l6n8hzdXYhPuJcGtziTOgUpvsFWGIQ= +github.com/go-openapi/runtime v0.28.0/go.mod h1:QN7OzcS+XuYmkQLw05akXk0jRH/eZ3kb18+1KwW9gyc= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= +github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= +github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e h1:XmA6L9IPRdUr28a+SK/oMchGgQy159wvzXA5tJ7l+40= +github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e/go.mod h1:AFIo+02s+12CEg8Gzz9kzhCbmbq6JcKNrhHffCGA9z4= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= +github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.20.0 h1:uPJdOxF/Ipj7ABVNOAMJXSxwFXZGwMGHNqjC8e61VA0= +github.com/pressly/goose/v3 v3.20.0/go.mod h1:BRfF2GcG4FTG12QfdBVy3q1yveaf4ckL9vWwEcIO3lA= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= +github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sethvargo/go-retry v0.2.4 h1:T+jHEQy/zKJf5s95UkguisicE0zuF9y7+/vgz08Ocec= +github.com/sethvargo/go-retry v0.2.4/go.mod h1:1afjQuvh7s4gflMObvjLPaWgluLLyhA1wmVZ6KLpICw= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80= +go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= +go.openly.dev/pointy v1.3.0 h1:keht3ObkbDNdY8PWPwB7Kcqk+MAlNStk5kXZTxukE68= +go.openly.dev/pointy v1.3.0/go.mod h1:rccSKiQDQ2QkNfSVT2KG8Budnfhf3At8IWxy/3ElYes= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc= +go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs= +go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.26.0 h1:1u/AyyOqAWzy+SkPxDpahCNZParHV8Vid1RnI2clyDE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.26.0/go.mod h1:z46paqbJ9l7c9fIPCXTqTGwhQZ5XoTIsfeFYWboizjs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.26.0 h1:1wp/gyxsuYtuE/JFxsQRtcCDtMrO2qMvlfXALU5wkzI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.26.0/go.mod h1:gbTHmghkGgqxMomVQQMur1Nba4M0MQ8AYThXDUjsJ38= +go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30= +go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4= +go.opentelemetry.io/otel/sdk v1.26.0 h1:Y7bumHf5tAiDlRYFmGqetNcLaVUZmh4iYfmGxtmz7F8= +go.opentelemetry.io/otel/sdk v1.26.0/go.mod h1:0p8MXpqLeJ0pzcszQQN4F0S5FVjBLgypeGSngLsmirs= +go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA= +go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0= +go.opentelemetry.io/proto/otlp v1.2.0 h1:pVeZGk7nXDC9O2hncA6nHldxEjm6LByfA2aN8IOkz94= +go.opentelemetry.io/proto/otlp v1.2.0/go.mod h1:gGpR8txAl5M03pDhMC79G6SdqNV26naRm/KDsgaHD8A= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de h1:jFNzHPIeuzhdRwVhbZdiym9q0ory/xY3sA+v2wPg8I0= +google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:5iCWqnniDlqZHrd3neWVTOwvh/v6s3232omMecelax8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= +google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk= +modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.29.6 h1:0lOXGrycJPptfHDuohfYgNqoe4hu+gYuN/pKgY5XjS4= +modernc.org/sqlite v1.29.6/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/console/service/internal/configuration/config.go b/console/service/internal/configuration/config.go new file mode 100644 index 000000000..5f44fde57 --- /dev/null +++ b/console/service/internal/configuration/config.go @@ -0,0 +1,77 @@ +package configuration + +import ( + "fmt" + "time" + + "github.com/kelseyhightower/envconfig" +) + +type Config struct { + Logger struct { + Level string `default:"DEBUG" desc:"Log level. Accepted values: [TRACE, DEBUG, INFO, WARN, ERROR, FATAL, PANIC]"` + } + Http struct { + Host string `default:"0.0.0.0" desc:"Accepted host for connection. '0.0.0.0' for all hosts"` + Port int `default:"8080" desc:"Listening port"` + WriteTimeout time.Duration `default:"10s" desc:"Maximum duration before timing out write of the response"` + ReadTimeout time.Duration `default:"10s" desc:"Maximum duration before timing out read of the request"` + } + Https struct { + IsUsed bool `default:"false" desc:"Flag for turn on/off https"` + Host string `default:"0.0.0.0" desc:"Accepted host for connection. '0.0.0.0' for all hosts"` + Port int `default:"8081" desc:"Listening port"` + CACert string `default:"/etc/pg_console/cacert.pem" desc:"The certificate to use for secure connections"` + ServerCert string `default:"/etc/pg_console/server-cert.pem" desc:"The certificate authority file to be used with mutual tls auth"` + ServerKey string `default:"/etc/pg_console/server-key.pem" desc:"The private key to use for secure connections"` + } + Authorization struct { + Token string `default:"auth_token" desc:"Authorization token for REST API"` + } + Db struct { + Host string `default:"localhost" desc:"Database host"` + Port uint16 `default:"5432" desc:"Database port"` + DbName string `default:"postgres" desc:"Database name"` + User string `default:"postgres" desc:"Database user name"` + Password string `default:"postgres-pass" desc:"Database user password"` + MaxConns int32 `default:"10" desc:"MaxConns is the maximum size of the pool"` + MaxConnLifeTime time.Duration `default:"60s" desc:"MaxConnLifetime is the duration since creation after which a connection will be automatically closed"` + MaxConnIdleTime time.Duration `default:"60s" desc:"MaxConnIdleTime is the duration after which an idle connection will be automatically closed by the health check"` + MigrationDir string `default:"/etc/db/migrations" desc:"Path to directory with migration scripts"` + } + EncryptionKey string `default:"super_secret" desc:"Encryption key for secret storage"` + Docker struct { + Host string `default:"unix:///var/run/docker.sock" desc:"Docker host"` + LogDir string `default:"/tmp/ansible" desc:"Directory inside docker container for ansible json log"` + Image string `default:"vitabaks/postgresql_cluster:2.0.0" desc:"Docker image for postgresql_cluster"` + } + LogWatcher struct { + RunEvery time.Duration `default:"1m" desc:"LogWatcher run interval"` + AnalyzePast time.Duration `default:"48h" desc:"LogWatcher gets operations to analyze which created_at > now() - AnalyzePast"` + } + ClusterWatcher struct { + RunEvery time.Duration `default:"1m" desc:"ClusterWatcher run interval"` + PoolSize int64 `default:"4" desc:"Amount of async request from ClusterWatcher"` + } +} + +const cfgPrefix = "PG_CONSOLE" + +func ReadConfig() (*Config, error) { + cfg := Config{} + + err := envconfig.Process(cfgPrefix, &cfg) + if err != nil { + return nil, fmt.Errorf("failed to parse config: %s", err.Error()) + } + + return &cfg, nil +} + +func PrintUsage() { + cfg := Config{} + err := envconfig.Usage(cfgPrefix, &cfg) + if err != nil { + fmt.Printf("failed to print envconfig usage: %s", err.Error()) + } +} diff --git a/console/service/internal/controllers/cluster/delete_cluster.go b/console/service/internal/controllers/cluster/delete_cluster.go new file mode 100644 index 000000000..57235d2c1 --- /dev/null +++ b/console/service/internal/controllers/cluster/delete_cluster.go @@ -0,0 +1,28 @@ +package cluster + +import ( + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/restapi/operations/cluster" + + "github.com/go-openapi/runtime/middleware" +) + +type deleteClusterHandler struct { + db storage.IStorage +} + +func NewDeleteClusterHandler(db storage.IStorage) cluster.DeleteClustersIDHandler { + return &deleteClusterHandler{ + db: db, + } +} + +func (h *deleteClusterHandler) Handle(param cluster.DeleteClustersIDParams) middleware.Responder { + err := h.db.DeleteClusterSoft(param.HTTPRequest.Context(), param.ID) + if err != nil { + return cluster.NewDeleteClustersIDBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return cluster.NewDeleteClustersIDNoContent() +} diff --git a/console/service/internal/controllers/cluster/delete_server.go b/console/service/internal/controllers/cluster/delete_server.go new file mode 100644 index 000000000..87467ab17 --- /dev/null +++ b/console/service/internal/controllers/cluster/delete_server.go @@ -0,0 +1,42 @@ +package cluster + +import ( + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/pkg/tracer" + "postgresql-cluster-console/restapi/operations/cluster" + + "github.com/go-openapi/runtime/middleware" + "github.com/rs/zerolog" +) + +type deleteServerHandler struct { + db storage.IStorage + log zerolog.Logger +} + +func NewDeleteServerHandler(db storage.IStorage, log zerolog.Logger) cluster.DeleteServersIDHandler { + return &deleteServerHandler{ + db: db, + log: log, + } +} + +func (h *deleteServerHandler) Handle(param cluster.DeleteServersIDParams) middleware.Responder { + cid := param.HTTPRequest.Context().Value(tracer.CtxCidKey{}).(string) + localLog := h.log.With().Str("cid", cid).Logger() + deletedServer, err := h.db.GetServer(param.HTTPRequest.Context(), param.ID) + if err != nil { + localLog.Warn().Err(err).Msg("failed to get server from db") + } + clusterID := int64(-1) + if deletedServer != nil { + clusterID = deletedServer.ClusterID + } + err = h.db.DeleteServer(param.HTTPRequest.Context(), param.ID) + if err != nil { + return cluster.NewDeleteServersIDBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return cluster.NewDeleteServersIDNoContent().WithXClusterID(clusterID) +} diff --git a/console/service/internal/controllers/cluster/get_cluster.go b/console/service/internal/controllers/cluster/get_cluster.go new file mode 100644 index 000000000..f6d842edd --- /dev/null +++ b/console/service/internal/controllers/cluster/get_cluster.go @@ -0,0 +1,58 @@ +package cluster + +import ( + "context" + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/convert" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + "postgresql-cluster-console/restapi/operations/cluster" + + "github.com/go-openapi/runtime/middleware" + "github.com/rs/zerolog" +) + +type getClusterHandler struct { + db storage.IStorage + log zerolog.Logger +} + +func NewGetClusterHandler(db storage.IStorage, log zerolog.Logger) cluster.GetClustersIDHandler { + return &getClusterHandler{ + db: db, + log: log, + } +} + +func (h *getClusterHandler) Handle(param cluster.GetClustersIDParams) middleware.Responder { + cl, err := h.db.GetCluster(param.HTTPRequest.Context(), param.ID) + if err != nil { + return cluster.NewGetClustersIDBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + resp, err := getClusterInfo(param.HTTPRequest.Context(), h.db, cl) + if err != nil { + return cluster.NewGetClustersIDBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return cluster.NewGetClustersIDOK().WithPayload(resp) +} + +func getClusterInfo(ctx context.Context, db storage.IStorage, cl *storage.Cluster) (*models.ClusterInfo, error) { + project, err := db.GetProject(ctx, cl.ProjectID) + if err != nil { + return nil, err + } + + environment, err := db.GetEnvironment(ctx, cl.EnvironmentID) + if err != nil { + return nil, err + } + + servers, err := db.GetClusterServers(ctx, cl.ID) + if err != nil { + return nil, err + } + + return convert.ClusterToSwagger(cl, servers, environment.Name, project.Name), nil +} diff --git a/console/service/internal/controllers/cluster/get_cluster_default_name.go b/console/service/internal/controllers/cluster/get_cluster_default_name.go new file mode 100644 index 000000000..1288d2f26 --- /dev/null +++ b/console/service/internal/controllers/cluster/get_cluster_default_name.go @@ -0,0 +1,34 @@ +package cluster + +import ( + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + "postgresql-cluster-console/restapi/operations/cluster" + + "github.com/go-openapi/runtime/middleware" + "github.com/rs/zerolog" +) + +type getClusterDefaultNameHandler struct { + db storage.IStorage + log zerolog.Logger +} + +func NewGetClusterDefaultNameHandler(db storage.IStorage, log zerolog.Logger) cluster.GetClustersDefaultNameHandler { + return &getClusterDefaultNameHandler{ + db: db, + log: log, + } +} + +func (h *getClusterDefaultNameHandler) Handle(param cluster.GetClustersDefaultNameParams) middleware.Responder { + name, err := h.db.GetDefaultClusterName(param.HTTPRequest.Context()) + if err != nil { + return cluster.NewGetClustersDefaultNameBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return cluster.NewGetClustersDefaultNameOK().WithPayload(&models.ResponseClusterDefaultName{ + Name: name, + }) +} diff --git a/console/service/internal/controllers/cluster/get_clusters.go b/console/service/internal/controllers/cluster/get_clusters.go new file mode 100644 index 000000000..88cc1968c --- /dev/null +++ b/console/service/internal/controllers/cluster/get_clusters.go @@ -0,0 +1,108 @@ +package cluster + +import ( + "context" + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/convert" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + "postgresql-cluster-console/pkg/tracer" + "postgresql-cluster-console/restapi/operations/cluster" + "time" + + "github.com/go-openapi/runtime/middleware" + "github.com/rs/zerolog" +) + +type getClustersHandler struct { + db storage.IStorage + log zerolog.Logger +} + +func NewGetClustersHandler(db storage.IStorage, log zerolog.Logger) cluster.GetClustersHandler { + return &getClustersHandler{ + db: db, + log: log, + } +} + +func (h *getClustersHandler) Handle(param cluster.GetClustersParams) middleware.Responder { + cid := param.HTTPRequest.Context().Value(tracer.CtxCidKey{}).(string) + localLog := h.log.With().Str("cid", cid).Logger() + + project, err := h.db.GetProject(param.HTTPRequest.Context(), param.ProjectID) + if err != nil { + return cluster.NewGetClustersBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + clusters, meta, err := h.db.GetClusters(param.HTTPRequest.Context(), &storage.GetClustersReq{ + ProjectID: param.ProjectID, + Name: param.Name, + SortBy: param.SortBy, + Status: param.Status, + Location: param.Location, + ServerCount: param.ServerCount, + PostgresVersion: param.PostgresVersion, + EnvironmentID: func() *int64 { + if param.Environment == nil { + return nil + } + environment, err := h.db.GetEnvironmentByName(param.HTTPRequest.Context(), *param.Environment) + if err != nil { + localLog.Error().Err(err).Msg("failed to get environment from db") + + return nil + } + + return &environment.ID + }(), + CreatedAtFrom: (*time.Time)(param.CreatedAtFrom), + CreatedAtTo: (*time.Time)(param.CreatedAtTo), + Limit: param.Limit, + Offset: param.Offset, + }) + if err != nil { + return cluster.NewGetClustersBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + clustersResp := models.ResponseClustersInfo{ + Data: make([]*models.ClusterInfo, 0, len(clusters)), + Meta: &models.MetaPagination{ + Count: &meta.Count, + Limit: &meta.Limit, + Offset: &meta.Offset, + }, + } + + cache := make(map[int64]string) + for _, cl := range clusters { + servers, err := h.db.GetClusterServers(param.HTTPRequest.Context(), cl.ID) + if err != nil { + return cluster.NewGetClustersBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + environmentCode, err := h.getEnvironmentCode(param.HTTPRequest.Context(), cl.EnvironmentID, cache) + if err != nil { + return cluster.NewGetClustersBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + clustersResp.Data = append(clustersResp.Data, convert.ClusterToSwagger(&cl, servers, environmentCode, project.Name)) + } + + return cluster.NewGetClustersOK().WithPayload(&clustersResp) +} + +func (h *getClustersHandler) getEnvironmentCode(ctx context.Context, environmentID int64, cache map[int64]string) (string, error) { + code, ok := cache[environmentID] + if ok { + return code, nil + } + + environment, err := h.db.GetEnvironment(ctx, environmentID) + if err != nil { + return "", err + } + + cache[environmentID] = environment.Name + + return environment.Name, nil +} diff --git a/console/service/internal/controllers/cluster/post_cluster.go b/console/service/internal/controllers/cluster/post_cluster.go new file mode 100644 index 000000000..b67ac6984 --- /dev/null +++ b/console/service/internal/controllers/cluster/post_cluster.go @@ -0,0 +1,249 @@ +package cluster + +import ( + "encoding/json" + "fmt" + "postgresql-cluster-console/internal/configuration" + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/internal/watcher" + "postgresql-cluster-console/internal/xdocker" + "postgresql-cluster-console/models" + "postgresql-cluster-console/pkg/tracer" + "postgresql-cluster-console/restapi/operations/cluster" + "strconv" + "strings" + + "github.com/go-openapi/runtime/middleware" + "github.com/rs/zerolog" + "github.com/segmentio/asm/base64" + "go.openly.dev/pointy" +) + +type postClusterHandler struct { + db storage.IStorage + dockerManager xdocker.IManager + logCollector watcher.LogCollector + log zerolog.Logger + cfg *configuration.Config +} + +func NewPostClusterHandler(db storage.IStorage, dockerManager xdocker.IManager, logCollector watcher.LogCollector, cfg *configuration.Config, log zerolog.Logger) cluster.PostClustersHandler { + return &postClusterHandler{ + db: db, + dockerManager: dockerManager, + logCollector: logCollector, + log: log, + cfg: cfg, + } +} + +func (h *postClusterHandler) Handle(param cluster.PostClustersParams) middleware.Responder { + cid := param.HTTPRequest.Context().Value(tracer.CtxCidKey{}).(string) + localLog := h.log.With().Str("cid", cid).Logger() + oldCluster, err := h.db.GetClusterByName(param.HTTPRequest.Context(), param.Body.Name) + if err != nil { + localLog.Warn().Err(err).Msg("can't get cluster by name") + } + if oldCluster != nil { + localLog.Trace().Any("old_cluster", oldCluster).Msg("cluster already exists") + + return cluster.NewPostClustersBadRequest().WithPayload(controllers.MakeErrorPayload(fmt.Errorf("cluster %s already exists", param.Body.Name), controllers.BaseError)) + } + + var ( + secretEnvs []string + secretID *int64 + paramLocation ParamLocation + ) + if param.Body.AuthInfo != nil { + secretEnvs, paramLocation, err = getSecretEnvs(param.HTTPRequest.Context(), h.log, h.db, param.Body.AuthInfo.SecretID, h.cfg.EncryptionKey) + if err != nil { + localLog.Error().Err(err).Msg("failed to get secret") + + return cluster.NewPostClustersBadRequest().WithPayload(controllers.MakeErrorPayload(fmt.Errorf("failed to get secret: %s", err.Error()), controllers.BaseError)) + } + secretID = ¶m.Body.AuthInfo.SecretID + localLog.Trace().Strs("secretEnvs", secretEnvs).Msg("got secret") + } else { + localLog.Debug().Msg("AuthInfo is nil, secret is expected in envs from web") + } + + ansibleLogEnv := h.getAnsibleLogEnv(param.Body.Name) + localLog.Trace().Strs("file_log", ansibleLogEnv).Msg("got file log name") + + if paramLocation == EnvParamLocation { + param.Body.Envs = append(param.Body.Envs, secretEnvs...) + } else if paramLocation == ExtraVarsParamLocation { + param.Body.ExtraVars = append(param.Body.ExtraVars, secretEnvs...) + } + 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) + + const ( + LocationExtraVar = "server_location" + CloudProviderExtraVar = "cloud_provider" + ServersExtraVar = "server_count" + PostgreSqlVersionExtraVar = "postgresql_version" + InventoryJsonEnv = "ANSIBLE_INVENTORY_JSON" + ) + + var ( + serverCount int + inventoryJsonVal []byte + ) + + if getValFromVars(param.Body.ExtraVars, CloudProviderExtraVar) == "" { + inventoryJsonVal = []byte(getValFromVars(param.Body.Envs, InventoryJsonEnv)) + var inventoryJson InventoryJson + err = json.Unmarshal(inventoryJsonVal, &inventoryJson) + if err != nil { + localLog.Debug().Err(err).Str("inventory_json_val", string(inventoryJsonVal)).Msg("failed to parse inventory json, try to base64 decode") + inventoryJsonVal, err = base64.StdEncoding.DecodeString(string(inventoryJsonVal)) + if err != nil { + localLog.Debug().Err(err).Msg("failed to base64 decode inventory json") + inventoryJsonVal = nil // to correct insert in db + } else { + err = json.Unmarshal(inventoryJsonVal, &inventoryJson) + if err != nil { + localLog.Debug().Err(err).Str("inventory_json_val", string(inventoryJsonVal)).Msg("failed to parse inventory json") + inventoryJsonVal = nil // to correct insert to db + } else { + serverCount = len(inventoryJson.All.Children.Master.Hosts) + len(inventoryJson.All.Children.Replica.Hosts) + } + } + } else { + serverCount = len(inventoryJson.All.Children.Master.Hosts) + len(inventoryJson.All.Children.Replica.Hosts) + } + } else { + serverCount = getIntValFromVars(param.Body.ExtraVars, ServersExtraVar) + } + + 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), + ServerCount: serverCount, + PostgreSqlVersion: getIntValFromVars(param.Body.ExtraVars, PostgreSqlVersionExtraVar), + Status: "deploying", + Inventory: inventoryJsonVal, + }) + if err != nil { + return cluster.NewPostClustersBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + localLog.Info().Any("cluster", createdCluster).Msg("cluster was created") + + defer func() { + if err != nil { + _, err = h.db.UpdateCluster(param.HTTPRequest.Context(), &storage.UpdateClusterReq{ + ID: createdCluster.ID, + Status: pointy.String(storage.ClusterStatusFailed), + }) + if err != nil { + localLog.Error().Err(err).Msg("failed to update cluster") + } + } + }() + + var dockerId xdocker.InstanceID + dockerId, err = h.dockerManager.ManageCluster(param.HTTPRequest.Context(), &xdocker.ManageClusterConfig{ + Envs: param.Body.Envs, + ExtraVars: param.Body.ExtraVars, + Mounts: []xdocker.Mount{ + { + DockerPath: ansibleLogDir, + HostPath: h.cfg.Docker.LogDir, + }, + }, + }) + if err != nil { + return cluster.NewPostClustersBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + localLog.Info().Str("docker_id", string(dockerId)).Msg("docker was started") + + var createdOperation *storage.Operation + createdOperation, err = h.db.CreateOperation(param.HTTPRequest.Context(), &storage.CreateOperationReq{ + ProjectID: param.Body.ProjectID, + ClusterID: createdCluster.ID, + DockerCode: string(dockerId), + Type: storage.OperationTypeDeploy, + Cid: cid, + }) + if err != nil { + return cluster.NewPostClustersBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + localLog.Info().Any("operation", createdOperation).Msg("operation was created") + h.logCollector.StoreInDb(createdOperation.ID, dockerId, cid) + + return cluster.NewPostClustersOK().WithPayload(&models.ResponseClusterCreate{ + ClusterID: createdCluster.ID, + OperationID: createdOperation.ID, + }) +} + +func (h *postClusterHandler) addProxySettings(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") + } + 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") + } + } +} + +const ansibleLogDir = "/tmp/ansible" + +func (h *postClusterHandler) getAnsibleLogEnv(clusterName string) []string { + return []string{"ANSIBLE_JSON_LOG_FILE=" + ansibleLogDir + "/" + clusterName + ".json"} +} + +func getValFromVars(vars []string, key string) string { + for _, extraVar := range vars { + if strings.HasPrefix(strings.ToLower(extraVar), strings.ToLower(key)) { + keyVal := strings.Split(extraVar, "=") + if len(keyVal) != 2 { + return "" + } + + return keyVal[1] + } + } + + return "" +} + +func getIntValFromVars(vars []string, key string) int { + valStr := getValFromVars(vars, key) + valInt, err := strconv.Atoi(valStr) + if err != nil { + return 0 + } + + return valInt +} + +type InventoryJson struct { + All struct { + Children struct { + Master struct { + Hosts map[string]interface{} `json:"hosts"` + } `json:"master"` + Replica struct { + Hosts map[string]interface{} `json:"hosts"` + } `json:"replica"` + } `json:"children"` + } `json:"all"` +} diff --git a/console/service/internal/controllers/cluster/post_cluster_refresh.go b/console/service/internal/controllers/cluster/post_cluster_refresh.go new file mode 100644 index 000000000..445b70b07 --- /dev/null +++ b/console/service/internal/controllers/cluster/post_cluster_refresh.go @@ -0,0 +1,41 @@ +package cluster + +import ( + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/internal/watcher" + "postgresql-cluster-console/restapi/operations/cluster" + + "github.com/go-openapi/runtime/middleware" + "github.com/rs/zerolog" +) + +type postClusterRefreshHandler struct { + db storage.IStorage + log zerolog.Logger + clusterWatcher watcher.ClusterWatcher +} + +func NewPostClusterRefreshHandler(db storage.IStorage, log zerolog.Logger, clusterWatcher watcher.ClusterWatcher) cluster.PostClustersIDRefreshHandler { + return &postClusterRefreshHandler{ + db: db, + log: log, + clusterWatcher: clusterWatcher, + } +} + +func (h *postClusterRefreshHandler) Handle(param cluster.PostClustersIDRefreshParams) middleware.Responder { + cl, err := h.db.GetCluster(param.HTTPRequest.Context(), param.ID) + if err != nil { + return cluster.NewPostClustersIDRefreshBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + h.clusterWatcher.HandleCluster(param.HTTPRequest.Context(), cl) + + resp, err := getClusterInfo(param.HTTPRequest.Context(), h.db, cl) + if err != nil { + return cluster.NewPostClustersIDRefreshBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return cluster.NewPostClustersIDRefreshOK().WithPayload(resp) +} diff --git a/console/service/internal/controllers/cluster/remove_cluster.go b/console/service/internal/controllers/cluster/remove_cluster.go new file mode 100644 index 000000000..1453d1f80 --- /dev/null +++ b/console/service/internal/controllers/cluster/remove_cluster.go @@ -0,0 +1,88 @@ +package cluster + +import ( + "encoding/base64" + "encoding/json" + "postgresql-cluster-console/internal/configuration" + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/internal/watcher" + "postgresql-cluster-console/internal/xdocker" + "postgresql-cluster-console/pkg/tracer" + "postgresql-cluster-console/restapi/operations/cluster" + + "github.com/go-openapi/runtime/middleware" + "github.com/rs/zerolog" +) + +type removeClusterHandler struct { + db storage.IStorage + dockerManager xdocker.IManager + logCollector watcher.LogCollector + log zerolog.Logger + cfg *configuration.Config +} + +func NewRemoveClusterHandler(db storage.IStorage, dockerManager xdocker.IManager, logCollector watcher.LogCollector, cfg *configuration.Config, log zerolog.Logger) cluster.PostClustersIDRemoveHandler { + return &removeClusterHandler{ + db: db, + dockerManager: dockerManager, + logCollector: logCollector, + log: log, + cfg: cfg, + } +} + +func (h *removeClusterHandler) Handle(param cluster.PostClustersIDRemoveParams) middleware.Responder { + cid := param.HTTPRequest.Context().Value(tracer.CtxCidKey{}).(string) + localLog := h.log.With().Str("cid", cid).Logger() + clusterInfo, err := h.db.GetCluster(param.HTTPRequest.Context(), param.ID) + if err != nil { + 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)) + } + extraVars = append(extraVars, "state=absent") + + var ( + envs []string + paramLocation ParamLocation + ) + if clusterInfo.SecretID != nil { + envs, paramLocation, err = getSecretEnvs(param.HTTPRequest.Context(), h.log, h.db, *clusterInfo.SecretID, h.cfg.EncryptionKey) + if err != nil { + return cluster.NewPostClustersIDRemoveBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + if paramLocation == ExtraVarsParamLocation { + extraVars = append(extraVars, envs...) + } + } + 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") + + dockerId, err := h.dockerManager.ManageCluster(param.HTTPRequest.Context(), &xdocker.ManageClusterConfig{ + Envs: envs, + ExtraVars: extraVars, + }) + if err != nil { + return cluster.NewPostClustersIDRemoveBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + localLog.Trace().Str("docker_id", string(dockerId)).Msg("docker was started") + + err = h.db.DeleteCluster(param.HTTPRequest.Context(), clusterInfo.ID) + if err != nil { + return cluster.NewPostClustersIDRemoveBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + h.logCollector.PrintToConsole(dockerId, cid) + + return cluster.NewPostClustersIDRemoveNoContent() +} diff --git a/console/service/internal/controllers/cluster/utils.go b/console/service/internal/controllers/cluster/utils.go new file mode 100644 index 000000000..7dc32f46f --- /dev/null +++ b/console/service/internal/controllers/cluster/utils.go @@ -0,0 +1,99 @@ +package cluster + +import ( + "context" + "encoding/json" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + "postgresql-cluster-console/pkg/tracer" + + "github.com/rs/zerolog" +) + +type ParamLocation uint8 + +const ( + UnknownParamLocation ParamLocation = 0 + EnvParamLocation ParamLocation = 1 + ExtraVarsParamLocation ParamLocation = 2 +) + +func getSecretEnvs(ctx context.Context, log zerolog.Logger, db storage.IStorage, secretID int64, secretKey string) ([]string, ParamLocation, error) { + localLog := log.With().Str("cid", ctx.Value(tracer.CtxCidKey{}).(string)).Logger() + secretView, err := db.GetSecret(ctx, secretID) + if err != nil { + return nil, UnknownParamLocation, err + } + localLog.Trace().Any("secret_view", secretView).Msg("got secret view from db") + secretVal, err := db.GetSecretVal(ctx, secretID, secretKey) + if err != nil { + return nil, UnknownParamLocation, err + } + localLog.Trace().Msgf("secretVal %s", string(secretVal)) + + switch models.SecretType(secretView.Type) { + case models.SecretTypeAws: + var sec models.RequestSecretValueAws + err = json.Unmarshal(secretVal, &sec) + if err != nil { + return nil, UnknownParamLocation, err + } + + return []string{"AWS_ACCESS_KEY_ID=" + sec.AWSACCESSKEYID, "AWS_SECRET_ACCESS_KEY=" + sec.AWSSECRETACCESSKEY}, EnvParamLocation, nil + case models.SecretTypeGcp: + var sec models.RequestSecretValueGcp + err = json.Unmarshal(secretVal, &sec) + if err != nil { + return nil, UnknownParamLocation, err + } + + return []string{"GCP_SERVICE_ACCOUNT_CONTENTS=" + sec.GCPSERVICEACCOUNTCONTENTS}, EnvParamLocation, nil + case models.SecretTypeAzure: + var sec models.RequestSecretValueAzure + err = json.Unmarshal(secretVal, &sec) + if err != nil { + return nil, UnknownParamLocation, err + } + + return []string{ + "AZURE_SUBSCRIPTION_ID=" + sec.AZURESUBSCRIPTIONID, + "AZURE_CLIENT_ID=" + sec.AZURECLIENTID, + "AZURE_SECRET=" + sec.AZURESECRET, + "AZURE_TENANT=" + sec.AZURETENANT, + }, EnvParamLocation, nil + case models.SecretTypeDigitalocean: + var sec models.RequestSecretValueDigitalOcean + err = json.Unmarshal(secretVal, &sec) + if err != nil { + return nil, UnknownParamLocation, err + } + + return []string{"DO_API_TOKEN=" + sec.DOAPITOKEN}, EnvParamLocation, nil + case models.SecretTypeHetzner: + var sec models.RequestSecretValueHetzner + err = json.Unmarshal(secretVal, &sec) + if err != nil { + return nil, UnknownParamLocation, err + } + + return []string{"HCLOUD_API_TOKEN=" + sec.HCLOUDAPITOKEN}, EnvParamLocation, nil + case models.SecretTypeSSHKey: + var sec models.RequestSecretValueSSHKey + err = json.Unmarshal(secretVal, &sec) + if err != nil { + return nil, UnknownParamLocation, err + } + + return []string{"SSH_PRIVATE_KEY_CONTENT=" + sec.SSHPRIVATEKEY}, EnvParamLocation, nil + case models.SecretTypePassword: + var sec models.RequestSecretValuePassword + err = json.Unmarshal(secretVal, &sec) + if err != nil { + return nil, UnknownParamLocation, err + } + + return []string{"ansible_user=" + sec.USERNAME, "ansible_ssh_pass=" + sec.PASSWORD, "ansible_sudo_pass=" + sec.PASSWORD}, ExtraVarsParamLocation, nil + default: + return nil, UnknownParamLocation, nil + } +} diff --git a/console/service/internal/controllers/dictionary/get_database_extensions.go b/console/service/internal/controllers/dictionary/get_database_extensions.go new file mode 100644 index 000000000..92e7c42d0 --- /dev/null +++ b/console/service/internal/controllers/dictionary/get_database_extensions.go @@ -0,0 +1,42 @@ +package dictionary + +import ( + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/convert" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + "postgresql-cluster-console/restapi/operations/dictionary" + + "github.com/go-openapi/runtime/middleware" +) + +type getDbExtensionsHandler struct { + db storage.IStorage +} + +func NewGetDbExtensionsHandler(db storage.IStorage) dictionary.GetDatabaseExtensionsHandler { + return &getDbExtensionsHandler{ + db: db, + } +} + +func (h *getDbExtensionsHandler) Handle(param dictionary.GetDatabaseExtensionsParams) middleware.Responder { + extensions, meta, err := h.db.GetExtensions(param.HTTPRequest.Context(), &storage.GetExtensionsReq{ + Type: param.ExtensionType, + PostgresVersion: param.PostgresVersion, + Limit: param.Limit, + Offset: param.Offset, + }) + if err != nil { + return dictionary.NewGetDatabaseExtensionsBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return dictionary.NewGetDatabaseExtensionsOK().WithPayload(&models.ResponseDatabaseExtensions{ + Data: convert.DbExtensionsToSwagger(extensions), + Meta: &models.MetaPagination{ + Count: &meta.Count, + Limit: &meta.Limit, + Offset: &meta.Offset, + }, + }) +} diff --git a/console/service/internal/controllers/dictionary/get_external_deployments.go b/console/service/internal/controllers/dictionary/get_external_deployments.go new file mode 100644 index 000000000..e3a83e3dc --- /dev/null +++ b/console/service/internal/controllers/dictionary/get_external_deployments.go @@ -0,0 +1,47 @@ +package dictionary + +import ( + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/convert" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + "postgresql-cluster-console/restapi/operations/dictionary" + + "github.com/go-openapi/runtime/middleware" +) + +type getExternalDeploymentsHandler struct { + db storage.IStorage +} + +func NewGetExternalDeploymentsHandler(db storage.IStorage) dictionary.GetExternalDeploymentsHandler { + return &getExternalDeploymentsHandler{ + db: db, + } +} + +func (h *getExternalDeploymentsHandler) Handle(param dictionary.GetExternalDeploymentsParams) middleware.Responder { + cloudProviders, metaPagination, err := h.db.GetCloudProviders(param.HTTPRequest.Context(), param.Limit, param.Offset) + if err != nil { + return dictionary.NewGetExternalDeploymentsBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + resp := &models.ResponseDeploymentsInfo{ + Data: make([]*models.ResponseDeploymentInfo, 0, len(cloudProviders)), + Meta: &models.MetaPagination{ + Count: &metaPagination.Count, + Limit: &metaPagination.Limit, + Offset: &metaPagination.Offset, + }, + } + for _, cloudProvider := range cloudProviders { + cloudProviderInfo, err := h.db.GetCloudProviderInfo(param.HTTPRequest.Context(), cloudProvider.Code) + if err != nil { + return dictionary.NewGetDatabaseExtensionsBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + resp.Data = append(resp.Data, convert.ProviderInfoToSwagger(cloudProviderInfo, cloudProvider.Description, cloudProvider.ProviderImage)) + } + + return dictionary.NewGetExternalDeploymentsOK().WithPayload(resp) +} diff --git a/console/service/internal/controllers/dictionary/get_postgres_versions.go b/console/service/internal/controllers/dictionary/get_postgres_versions.go new file mode 100644 index 000000000..c8af12728 --- /dev/null +++ b/console/service/internal/controllers/dictionary/get_postgres_versions.go @@ -0,0 +1,32 @@ +package dictionary + +import ( + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/convert" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + "postgresql-cluster-console/restapi/operations/dictionary" + + "github.com/go-openapi/runtime/middleware" +) + +type getPostgresVersionsHandler struct { + db storage.IStorage +} + +func NewGetPostgresVersions(db storage.IStorage) dictionary.GetPostgresVersionsHandler { + return &getPostgresVersionsHandler{ + db: db, + } +} + +func (h *getPostgresVersionsHandler) Handle(param dictionary.GetPostgresVersionsParams) middleware.Responder { + postgresVersions, err := h.db.GetPostgresVersions(param.HTTPRequest.Context()) + if err != nil { + return dictionary.NewGetPostgresVersionsBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return dictionary.NewGetPostgresVersionsOK().WithPayload(&models.ResponsePostgresVersions{ + Data: convert.PostgresVersions(postgresVersions), + }) +} diff --git a/console/service/internal/controllers/environment/delete_environment.go b/console/service/internal/controllers/environment/delete_environment.go new file mode 100644 index 000000000..b21234701 --- /dev/null +++ b/console/service/internal/controllers/environment/delete_environment.go @@ -0,0 +1,41 @@ +package environment + +import ( + "errors" + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/pkg/tracer" + "postgresql-cluster-console/restapi/operations/environment" + + "github.com/go-openapi/runtime/middleware" + "github.com/rs/zerolog" +) + +type deleteEnvironmentsHandler struct { + db storage.IStorage + log zerolog.Logger +} + +func NewDeleteEnvironmentsHandler(db storage.IStorage, log zerolog.Logger) environment.DeleteEnvironmentsIDHandler { + return &deleteEnvironmentsHandler{ + db: db, + log: log, + } +} + +func (h *deleteEnvironmentsHandler) Handle(param environment.DeleteEnvironmentsIDParams) middleware.Responder { + cid := param.HTTPRequest.Context().Value(tracer.CtxCidKey{}).(string) + localLog := h.log.With().Str("cid", cid).Logger() + isUsed, err := h.db.CheckEnvironmentIsUsed(param.HTTPRequest.Context(), param.ID) + if err != nil { + localLog.Warn().Err(err).Msg("failed to check that environment is used") + } else if isUsed { + return environment.NewDeleteEnvironmentsIDBadRequest().WithPayload(controllers.MakeErrorPayload(errors.New("The environment is used"), controllers.BaseError)) + } + err = h.db.DeleteEnvironment(param.HTTPRequest.Context(), param.ID) + if err != nil { + return environment.NewDeleteEnvironmentsIDBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return environment.NewDeleteEnvironmentsIDNoContent() +} diff --git a/console/service/internal/controllers/environment/get_environments.go b/console/service/internal/controllers/environment/get_environments.go new file mode 100644 index 000000000..976cc76a2 --- /dev/null +++ b/console/service/internal/controllers/environment/get_environments.go @@ -0,0 +1,37 @@ +package environment + +import ( + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/convert" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + "postgresql-cluster-console/restapi/operations/environment" + + "github.com/go-openapi/runtime/middleware" +) + +type getEnvironmentsHandler struct { + db storage.IStorage +} + +func NewGetEnvironmentsHandler(db storage.IStorage) environment.GetEnvironmentsHandler { + return &getEnvironmentsHandler{ + db: db, + } +} + +func (h *getEnvironmentsHandler) Handle(param environment.GetEnvironmentsParams) middleware.Responder { + environments, meta, err := h.db.GetEnvironments(param.HTTPRequest.Context(), param.Limit, param.Offset) + if err != nil { + return environment.NewGetEnvironmentsBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return environment.NewGetEnvironmentsOK().WithPayload(&models.ResponseEnvironmentsList{ + Data: convert.EnvironmentsToSwagger(environments), + Meta: &models.MetaPagination{ + Count: &meta.Count, + Limit: &meta.Limit, + Offset: &meta.Offset, + }, + }) +} diff --git a/console/service/internal/controllers/environment/post_environment.go b/console/service/internal/controllers/environment/post_environment.go new file mode 100644 index 000000000..6fdef8eac --- /dev/null +++ b/console/service/internal/controllers/environment/post_environment.go @@ -0,0 +1,45 @@ +package environment + +import ( + "fmt" + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/convert" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/pkg/tracer" + "postgresql-cluster-console/restapi/operations/environment" + + "github.com/go-openapi/runtime/middleware" + "github.com/rs/zerolog" +) + +type postEnvironmentsHandler struct { + db storage.IStorage + log zerolog.Logger +} + +func NewPostEnvironmentsHandler(db storage.IStorage, log zerolog.Logger) environment.PostEnvironmentsHandler { + return &postEnvironmentsHandler{ + db: db, + log: log, + } +} + +func (h *postEnvironmentsHandler) Handle(param environment.PostEnvironmentsParams) middleware.Responder { + cid := param.HTTPRequest.Context().Value(tracer.CtxCidKey{}).(string) + localLog := h.log.With().Str("cid", cid).Logger() + checkEnv, err := h.db.GetEnvironmentByName(param.HTTPRequest.Context(), param.Body.Name) + if err != nil { + localLog.Warn().Err(err).Msg("failed to check environment name exists") + } else if checkEnv != nil { + return environment.NewPostEnvironmentsBadRequest().WithPayload(controllers.MakeErrorPayload(fmt.Errorf("The environment named %q already exists", param.Body.Name), controllers.BaseError)) + } + env, err := h.db.CreateEnvironment(param.HTTPRequest.Context(), &storage.AddEnvironmentReq{ + Name: param.Body.Name, + Description: param.Body.Description, + }) + if err != nil { + return environment.NewPostEnvironmentsBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return environment.NewPostEnvironmentsOK().WithPayload(convert.EnvironmentToSwagger(env)) +} diff --git a/console/service/internal/controllers/errors.go b/console/service/internal/controllers/errors.go new file mode 100644 index 000000000..916d930d1 --- /dev/null +++ b/console/service/internal/controllers/errors.go @@ -0,0 +1,5 @@ +package controllers + +const ( + BaseError = int64(100) +) diff --git a/console/service/internal/controllers/operation/get_operation_log.go b/console/service/internal/controllers/operation/get_operation_log.go new file mode 100644 index 000000000..c4449a153 --- /dev/null +++ b/console/service/internal/controllers/operation/get_operation_log.go @@ -0,0 +1,35 @@ +package operation + +import ( + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/restapi/operations/operation" + + "github.com/go-openapi/runtime/middleware" +) + +type getOperationLogHandler struct { + db storage.IStorage +} + +func NewGetOperationLogHandler(db storage.IStorage) operation.GetOperationsIDLogHandler { + return &getOperationLogHandler{ + db: db, + } +} + +func (h *getOperationLogHandler) Handle(param operation.GetOperationsIDLogParams) middleware.Responder { + op, err := h.db.GetOperation(param.HTTPRequest.Context(), param.ID) + if err != nil { + return operation.NewGetOperationsIDLogBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + var logMessage string + if op.Log != nil { + logMessage = *op.Log + } + + return operation.NewGetOperationsIDLogOK().WithPayload(logMessage).WithContentType("plain/text").WithXLogCompleted(func() bool { + return op.Status != storage.OperationStatusInProgress + }()) +} diff --git a/console/service/internal/controllers/operation/get_operations.go b/console/service/internal/controllers/operation/get_operations.go new file mode 100644 index 000000000..7fbb6f5cc --- /dev/null +++ b/console/service/internal/controllers/operation/get_operations.go @@ -0,0 +1,48 @@ +package operation + +import ( + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/convert" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + "postgresql-cluster-console/restapi/operations/operation" + "time" + + "github.com/go-openapi/runtime/middleware" +) + +type getOperationsHandler struct { + db storage.IStorage +} + +func NewGetOperationsHandler(db storage.IStorage) operation.GetOperationsHandler { + return &getOperationsHandler{ + db: db, + } +} + +func (h *getOperationsHandler) Handle(param operation.GetOperationsParams) middleware.Responder { + operations, meta, err := h.db.GetOperations(param.HTTPRequest.Context(), &storage.GetOperationsReq{ + ProjectID: param.ProjectID, + StartedFrom: time.Time(param.StartDate), + EndedTill: time.Time(param.EndDate), + ClusterName: param.ClusterName, + Type: param.Type, + Status: param.Status, + SortBy: param.SortBy, + Limit: param.Limit, + Offset: param.Offset, + }) + if err != nil { + return operation.NewGetOperationsBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return operation.NewGetOperationsOK().WithPayload(&models.ResponseOperationsList{ + Data: convert.OperationsViewToSwagger(operations), + Meta: &models.MetaPagination{ + Count: &meta.Count, + Limit: &meta.Limit, + Offset: &meta.Offset, + }, + }) +} diff --git a/console/service/internal/controllers/project/delete_project.go b/console/service/internal/controllers/project/delete_project.go new file mode 100644 index 000000000..17c8eaa92 --- /dev/null +++ b/console/service/internal/controllers/project/delete_project.go @@ -0,0 +1,62 @@ +package project + +import ( + "fmt" + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/pkg/tracer" + "postgresql-cluster-console/restapi/operations/project" + "strings" + + "github.com/go-openapi/runtime/middleware" + "github.com/rs/zerolog" +) + +type deleteProjectHandler struct { + db storage.IStorage + log zerolog.Logger +} + +func NewDeleteProjectHandler(db storage.IStorage, log zerolog.Logger) project.DeleteProjectsIDHandler { + return &deleteProjectHandler{ + db: db, + log: log, + } +} + +func (h *deleteProjectHandler) Handle(param project.DeleteProjectsIDParams) middleware.Responder { + cid := param.HTTPRequest.Context().Value(tracer.CtxCidKey{}).(string) + localLog := h.log.With().Str("cid", cid).Logger() + checkClusters, _, err := h.db.GetClusters(param.HTTPRequest.Context(), &storage.GetClustersReq{ + ProjectID: param.ID, + }) + if err != nil { + localLog.Warn().Err(err).Msg("failed to check that project is used") + } else if len(checkClusters) != 0 { + return project.NewDeleteProjectsIDBadRequest().WithPayload(controllers.MakeErrorPayload(fmt.Errorf("The project is used by %d cluster(s) (%s)", len(checkClusters), getClustersNameTitle(checkClusters)), controllers.BaseError)) + } + err = h.db.DeleteProject(param.HTTPRequest.Context(), param.ID) + if err != nil { + return project.NewDeleteProjectsIDBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return project.NewDeleteProjectsIDNoContent() +} + +func getClustersNameTitle(clusters []storage.Cluster) string { + const maxSize = 3 + title := strings.Builder{} + for i, cl := range clusters { + if i >= maxSize { + title.WriteString(",...") + + return title.String() + } + if i != 0 { + title.WriteString(",") + } + title.WriteString(cl.Name) + } + + return title.String() +} diff --git a/console/service/internal/controllers/project/get_projects.go b/console/service/internal/controllers/project/get_projects.go new file mode 100644 index 000000000..810e65c8b --- /dev/null +++ b/console/service/internal/controllers/project/get_projects.go @@ -0,0 +1,37 @@ +package project + +import ( + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/convert" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + "postgresql-cluster-console/restapi/operations/project" + + "github.com/go-openapi/runtime/middleware" +) + +type getProjectsHandler struct { + db storage.IStorage +} + +func NewGetProjectsHandler(db storage.IStorage) project.GetProjectsHandler { + return &getProjectsHandler{ + db: db, + } +} + +func (h *getProjectsHandler) Handle(param project.GetProjectsParams) middleware.Responder { + projects, meta, err := h.db.GetProjects(param.HTTPRequest.Context(), param.Limit, param.Offset) + if err != nil { + return project.NewGetProjectsBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return project.NewGetProjectsOK().WithPayload(&models.ResponseProjectsList{ + Data: convert.ProjectsToSwagger(projects), + Meta: &models.MetaPagination{ + Count: &meta.Count, + Limit: &meta.Limit, + Offset: &meta.Offset, + }, + }) +} diff --git a/console/service/internal/controllers/project/path_project.go b/console/service/internal/controllers/project/path_project.go new file mode 100644 index 000000000..4cc548241 --- /dev/null +++ b/console/service/internal/controllers/project/path_project.go @@ -0,0 +1,29 @@ +package project + +import ( + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/convert" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/restapi/operations/project" + + "github.com/go-openapi/runtime/middleware" +) + +type patchProjectHandler struct { + db storage.IStorage +} + +func NewPatchProjectHandler(db storage.IStorage) project.PatchProjectsIDHandler { + return &patchProjectHandler{ + db: db, + } +} + +func (h *patchProjectHandler) Handle(param project.PatchProjectsIDParams) middleware.Responder { + updatedProject, err := h.db.UpdateProject(param.HTTPRequest.Context(), param.ID, param.Body.Name, param.Body.Description) + if err != nil { + return project.NewPatchProjectsIDBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return project.NewPatchProjectsIDOK().WithPayload(convert.ProjectToSwagger(updatedProject)) +} diff --git a/console/service/internal/controllers/project/post_project.go b/console/service/internal/controllers/project/post_project.go new file mode 100644 index 000000000..042867b9d --- /dev/null +++ b/console/service/internal/controllers/project/post_project.go @@ -0,0 +1,43 @@ +package project + +import ( + "fmt" + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/convert" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/pkg/tracer" + "postgresql-cluster-console/restapi/operations/project" + + "github.com/go-openapi/runtime/middleware" + "github.com/rs/zerolog" +) + +type postProjectHandler struct { + db storage.IStorage + log zerolog.Logger +} + +func NewPostProjectHandler(db storage.IStorage, log zerolog.Logger) project.PostProjectsHandler { + return &postProjectHandler{ + db: db, + log: log, + } +} + +func (h *postProjectHandler) Handle(param project.PostProjectsParams) middleware.Responder { + cid := param.HTTPRequest.Context().Value(tracer.CtxCidKey{}).(string) + localLog := h.log.With().Str("cid", cid).Logger() + checkProject, err := h.db.GetProjectByName(param.HTTPRequest.Context(), param.Body.Name) + if err != nil { + localLog.Warn().Err(err).Msg("failed to check project name exists") + } else if checkProject != nil { + return project.NewPostProjectsBadRequest().WithPayload(controllers.MakeErrorPayload(fmt.Errorf("The project %q named already exists", param.Body.Name), controllers.BaseError)) + } + + createdProject, err := h.db.CreateProject(param.HTTPRequest.Context(), param.Body.Name, param.Body.Description) + if err != nil { + return project.NewPostProjectsBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return project.NewPostProjectsOK().WithPayload(convert.ProjectToSwagger(createdProject)) +} diff --git a/console/service/internal/controllers/secret/delete_secret.go b/console/service/internal/controllers/secret/delete_secret.go new file mode 100644 index 000000000..50bf5164a --- /dev/null +++ b/console/service/internal/controllers/secret/delete_secret.go @@ -0,0 +1,28 @@ +package secret + +import ( + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/restapi/operations/secret" + + "github.com/go-openapi/runtime/middleware" +) + +type deleteSecretHandler struct { + db storage.IStorage +} + +func NewDeleteSecretHandler(db storage.IStorage) secret.DeleteSecretsIDHandler { + return &deleteSecretHandler{ + db: db, + } +} + +func (h *deleteSecretHandler) Handle(param secret.DeleteSecretsIDParams) middleware.Responder { + err := h.db.DeleteSecret(param.HTTPRequest.Context(), param.ID) + if err != nil { + return secret.NewDeleteSecretsIDBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return secret.NewDeleteSecretsIDNoContent() +} diff --git a/console/service/internal/controllers/secret/get_secrets.go b/console/service/internal/controllers/secret/get_secrets.go new file mode 100644 index 000000000..5b2498e9c --- /dev/null +++ b/console/service/internal/controllers/secret/get_secrets.go @@ -0,0 +1,44 @@ +package secret + +import ( + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/convert" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + "postgresql-cluster-console/restapi/operations/secret" + + "github.com/go-openapi/runtime/middleware" +) + +type getSecretHandler struct { + db storage.IStorage +} + +func NewGetSecretHandler(db storage.IStorage) secret.GetSecretsHandler { + return &getSecretHandler{ + db: db, + } +} + +func (h *getSecretHandler) Handle(param secret.GetSecretsParams) middleware.Responder { + secrets, meta, err := h.db.GetSecrets(param.HTTPRequest.Context(), &storage.GetSecretsReq{ + ProjectID: param.ProjectID, + Name: param.Name, + Type: param.Type, + SortBy: param.SortBy, + Limit: param.Limit, + Offset: param.Offset, + }) + if err != nil { + return secret.NewGetSecretsBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return secret.NewGetSecretsOK().WithPayload(&models.ResponseSecretInfoList{ + Data: convert.SecretsViewToSwagger(secrets), + Meta: &models.MetaPagination{ + Count: &meta.Count, + Limit: &meta.Limit, + Offset: &meta.Offset, + }, + }) +} diff --git a/console/service/internal/controllers/secret/post_secret.go b/console/service/internal/controllers/secret/post_secret.go new file mode 100644 index 000000000..efeb2da95 --- /dev/null +++ b/console/service/internal/controllers/secret/post_secret.go @@ -0,0 +1,77 @@ +package secret + +import ( + "encoding/json" + "fmt" + "postgresql-cluster-console/internal/configuration" + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/convert" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + "postgresql-cluster-console/pkg/tracer" + "postgresql-cluster-console/restapi/operations/secret" + + "github.com/go-openapi/runtime/middleware" + "github.com/rs/zerolog" +) + +type postSecretHandler struct { + db storage.IStorage + log zerolog.Logger + cfg *configuration.Config +} + +func NewPostSecretHandler(db storage.IStorage, log zerolog.Logger, cfg *configuration.Config) secret.PostSecretsHandler { + return &postSecretHandler{ + db: db, + log: log, + cfg: cfg, + } +} + +func (h *postSecretHandler) Handle(param secret.PostSecretsParams) middleware.Responder { + cid := param.HTTPRequest.Context().Value(tracer.CtxCidKey{}).(string) + localLog := h.log.With().Str("cid", cid).Logger() + checkSecret, err := h.db.GetSecretByName(param.HTTPRequest.Context(), param.Body.Name) + if err != nil { + localLog.Warn().Err(err).Msg("failed to check secret name exists") + } else if checkSecret != nil { + return secret.NewPostSecretsBadRequest().WithPayload(controllers.MakeErrorPayload(fmt.Errorf("The secret named %q already exists", param.Body.Name), controllers.BaseError)) + } + + var ( + value []byte + ) + switch param.Body.Type { + case models.SecretTypeAws: + value, err = json.Marshal(param.Body.Value.Aws) + case models.SecretTypeGcp: + value, err = json.Marshal(param.Body.Value.Gcp) + case models.SecretTypeHetzner: + value, err = json.Marshal(param.Body.Value.Hetzner) + case models.SecretTypeSSHKey: + value, err = json.Marshal(param.Body.Value.SSHKey) + case models.SecretTypeDigitalocean: + value, err = json.Marshal(param.Body.Value.Digitalocean) + case models.SecretTypeAzure: + value, err = json.Marshal(param.Body.Value.Azure) + case models.SecretTypePassword: + value, err = json.Marshal(param.Body.Value.Password) + } + if err != nil { + return secret.NewPostSecretsBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + createdSecret, err := h.db.CreateSecret(param.HTTPRequest.Context(), &storage.AddSecretReq{ + ProjectID: param.Body.ProjectID, + Type: string(param.Body.Type), + Name: param.Body.Name, + Value: value, + SecretKey: h.cfg.EncryptionKey, + }) + if err != nil { + return secret.NewPostSecretsBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return secret.NewPostSecretsOK().WithPayload(convert.SecretViewToSwagger(createdSecret)) +} diff --git a/console/service/internal/controllers/setting/get_settings.go b/console/service/internal/controllers/setting/get_settings.go new file mode 100644 index 000000000..3a8fa13bf --- /dev/null +++ b/console/service/internal/controllers/setting/get_settings.go @@ -0,0 +1,41 @@ +package setting + +import ( + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/convert" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + "postgresql-cluster-console/restapi/operations/setting" + + "github.com/go-openapi/runtime/middleware" +) + +type getSettingsHandler struct { + db storage.IStorage +} + +func NewGetSettingsHandler(db storage.IStorage) setting.GetSettingsHandler { + return &getSettingsHandler{ + db: db, + } +} + +func (h *getSettingsHandler) Handle(param setting.GetSettingsParams) middleware.Responder { + settings, meta, err := h.db.GetSettings(param.HTTPRequest.Context(), &storage.GetSettingsReq{ + Name: param.Name, + Limit: param.Limit, + Offset: param.Offset, + }) + if err != nil { + return setting.NewGetSettingsBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return setting.NewGetSettingsOK().WithPayload(&models.ResponseSettings{ + Data: convert.SettingsToSwagger(settings), + Mete: &models.MetaPagination{ + Count: &meta.Count, + Limit: &meta.Limit, + Offset: &meta.Offset, + }, + }) +} diff --git a/console/service/internal/controllers/setting/patch_setting.go b/console/service/internal/controllers/setting/patch_setting.go new file mode 100644 index 000000000..d14c7c109 --- /dev/null +++ b/console/service/internal/controllers/setting/patch_setting.go @@ -0,0 +1,29 @@ +package setting + +import ( + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/convert" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/restapi/operations/setting" + + "github.com/go-openapi/runtime/middleware" +) + +type patchSettingHandler struct { + db storage.IStorage +} + +func NewPatchSettingHandler(db storage.IStorage) setting.PatchSettingsNameHandler { + return &patchSettingHandler{ + db: db, + } +} + +func (h *patchSettingHandler) Handle(param setting.PatchSettingsNameParams) middleware.Responder { + s, err := h.db.UpdateSetting(param.HTTPRequest.Context(), param.Name, param.Body.Value) + if err != nil { + return setting.NewPatchSettingsNameBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return setting.NewPatchSettingsNameOK().WithPayload(convert.SettingToSwagger(s)) +} diff --git a/console/service/internal/controllers/setting/post_setting.go b/console/service/internal/controllers/setting/post_setting.go new file mode 100644 index 000000000..cc7b69017 --- /dev/null +++ b/console/service/internal/controllers/setting/post_setting.go @@ -0,0 +1,29 @@ +package setting + +import ( + "postgresql-cluster-console/internal/controllers" + "postgresql-cluster-console/internal/convert" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/restapi/operations/setting" + + "github.com/go-openapi/runtime/middleware" +) + +type postSettingHandler struct { + db storage.IStorage +} + +func NewPostSettingHandler(db storage.IStorage) setting.PostSettingsHandler { + return &postSettingHandler{ + db: db, + } +} + +func (h *postSettingHandler) Handle(param setting.PostSettingsParams) middleware.Responder { + s, err := h.db.CreateSetting(param.HTTPRequest.Context(), param.Body.Name, param.Body.Value) + if err != nil { + return setting.NewPostSettingsBadRequest().WithPayload(controllers.MakeErrorPayload(err, controllers.BaseError)) + } + + return setting.NewPostSettingsOK().WithPayload(convert.SettingToSwagger(s)) +} diff --git a/console/service/internal/controllers/utils.go b/console/service/internal/controllers/utils.go new file mode 100644 index 000000000..d47960e72 --- /dev/null +++ b/console/service/internal/controllers/utils.go @@ -0,0 +1,11 @@ +package controllers + +import "postgresql-cluster-console/models" + +func MakeErrorPayload(err error, code int64) *models.ResponseError { + return &models.ResponseError{ + Code: code, + Description: err.Error(), + Title: err.Error(), + } +} diff --git a/console/service/internal/convert/clusters.go b/console/service/internal/convert/clusters.go new file mode 100644 index 000000000..93024aa1c --- /dev/null +++ b/console/service/internal/convert/clusters.go @@ -0,0 +1,46 @@ +package convert + +import ( + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + + "github.com/go-openapi/strfmt" +) + +func ClusterToSwagger(cl *storage.Cluster, servers []storage.Server, environmentCode, projectCode string) *models.ClusterInfo { + clusterInfo := &models.ClusterInfo{ + ConnectionInfo: cl.ConnectionInfo, + CreationTime: strfmt.DateTime(cl.CreatedAt), + ClusterLocation: func() string { + if cl.Location != nil { + return *cl.Location + } + + return "" + }(), + Environment: environmentCode, + ID: cl.ID, + Servers: make([]*models.ClusterInfoInstance, 0, len(servers)), + Name: cl.Name, + Description: cl.Description, + PostgresVersion: cl.PostgreVersion, + ProjectName: projectCode, + Status: cl.Status, + } + + for _, server := range servers { + clusterInfo.Servers = append(clusterInfo.Servers, &models.ClusterInfoInstance{ + ID: server.ID, + IP: server.IpAddress.String(), + Lag: server.Lag, + Name: server.Name, + PendingRestart: server.PendingRestart, + Role: server.Role, + Status: server.Status, + Tags: server.Tags, + Timeline: server.Timeline, + }) + } + + return clusterInfo +} diff --git a/console/service/internal/convert/database_extensions.go b/console/service/internal/convert/database_extensions.go new file mode 100644 index 000000000..8d82928a6 --- /dev/null +++ b/console/service/internal/convert/database_extensions.go @@ -0,0 +1,27 @@ +package convert + +import ( + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" +) + +func DbExtensionToSwagger(ext *storage.Extension) *models.ResponseDatabaseExtension { + return &models.ResponseDatabaseExtension{ + Contrib: ext.Contrib, + Description: ext.Description, + Image: ext.Image, + Name: ext.Name, + PostgresMaxVersion: ext.PostgresMaxVersion, + PostgresMinVersion: ext.PostgresMinVersion, + URL: ext.Url, + } +} + +func DbExtensionsToSwagger(exts []storage.Extension) []*models.ResponseDatabaseExtension { + resp := make([]*models.ResponseDatabaseExtension, 0, len(exts)) + for _, ext := range exts { + resp = append(resp, DbExtensionToSwagger(&ext)) + } + + return resp +} diff --git a/console/service/internal/convert/environments.go b/console/service/internal/convert/environments.go new file mode 100644 index 000000000..d7e053636 --- /dev/null +++ b/console/service/internal/convert/environments.go @@ -0,0 +1,34 @@ +package convert + +import ( + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + + "github.com/go-openapi/strfmt" +) + +func EnvironmentToSwagger(env *storage.Environment) *models.ResponseEnvironment { + return &models.ResponseEnvironment{ + CreatedAt: strfmt.DateTime(env.CreatedAt), + Description: env.Description, + ID: env.ID, + Name: env.Name, + UpdatedAt: func() *strfmt.DateTime { + if env.UpdatedAt == nil { + return nil + } + updated := strfmt.DateTime(*env.UpdatedAt) + + return &updated + }(), + } +} + +func EnvironmentsToSwagger(envs []storage.Environment) []*models.ResponseEnvironment { + resp := make([]*models.ResponseEnvironment, 0, len(envs)) + for _, env := range envs { + resp = append(resp, EnvironmentToSwagger(&env)) + } + + return resp +} diff --git a/console/service/internal/convert/external_deployments.go b/console/service/internal/convert/external_deployments.go new file mode 100644 index 000000000..fafbf80c0 --- /dev/null +++ b/console/service/internal/convert/external_deployments.go @@ -0,0 +1,113 @@ +package convert + +import ( + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + "sort" + + "github.com/go-openapi/strfmt" + "go.openly.dev/pointy" +) + +func ProviderInfoToSwagger(providerInfo *storage.CloudProviderInfo, description, image string) *models.ResponseDeploymentInfo { + resp := &models.ResponseDeploymentInfo{ + AvatarURL: image, + CloudRegions: nil, + Code: providerInfo.Code, + Description: description, + Volumes: nil, + InstanceTypes: &models.ResponseDeploymentInfoInstanceTypes{}, + } + + cloudRegions := make(map[string][]*models.DeploymentInfoCloudRegionDatacentersItems0) + for _, cloudRegion := range providerInfo.CloudRegions { + datacenterRegion := &models.DeploymentInfoCloudRegionDatacentersItems0{ + Code: cloudRegion.RegionName, + Location: cloudRegion.Description, + } + cloudImage := findCloudImage(providerInfo.CloudImages, cloudRegion.RegionName) + if cloudImage == nil { + cloudImage = findCloudImage(providerInfo.CloudImages, "all") + } + if cloudImage != nil { + datacenterRegion.CloudImage = &models.DeploymentCloudImage{ + Arch: cloudImage.Arch, + Image: cloudImage.Image, + OsName: cloudImage.OsName, + OsVersion: cloudImage.OsVersion, + UpdatedAt: strfmt.DateTime(cloudImage.UpdatedAt), + } + } + cloudRegions[cloudRegion.RegionGroup] = append(cloudRegions[cloudRegion.RegionGroup], datacenterRegion) + } + + mapKeys := make([]string, 0, len(cloudRegions)) + for k, _ := range cloudRegions { + mapKeys = append(mapKeys, k) + } + sort.Strings(mapKeys) + + for _, k := range mapKeys { + resp.CloudRegions = append(resp.CloudRegions, &models.DeploymentInfoCloudRegion{ + Code: k, + Datacenters: cloudRegions[k], + Name: k, + }) + } + + for _, instance := range providerInfo.CloudInstances { + switch instance.InstanceGroup { + case storage.InstanceTypeSmall: + resp.InstanceTypes.Small = append(resp.InstanceTypes.Small, &models.DeploymentInstanceType{ + Code: instance.InstanceName, + CPU: instance.Cpu, + PriceHourly: instance.PriceHourly, + PriceMonthly: instance.PriceMonthly, + Currency: instance.Currency, + RAM: instance.Ram, + }) + case storage.InstanceTypeMedium: + resp.InstanceTypes.Medium = append(resp.InstanceTypes.Medium, &models.DeploymentInstanceType{ + Code: instance.InstanceName, + CPU: instance.Cpu, + PriceHourly: instance.PriceHourly, + PriceMonthly: instance.PriceMonthly, + Currency: instance.Currency, + RAM: instance.Ram, + }) + case storage.InstanceTypeLarge: + resp.InstanceTypes.Large = append(resp.InstanceTypes.Large, &models.DeploymentInstanceType{ + Code: instance.InstanceName, + CPU: instance.Cpu, + PriceHourly: instance.PriceHourly, + PriceMonthly: instance.PriceMonthly, + Currency: instance.Currency, + RAM: instance.Ram, + }) + } + } + + for _, cloudVolume := range providerInfo.CloudVolumes { + resp.Volumes = append(resp.Volumes, &models.ResponseDeploymentInfoVolumesItems0{ + Currency: cloudVolume.Currency, + MaxSize: cloudVolume.VolumeMaxSize, + MinSize: cloudVolume.VolumeMinSize, + PriceMonthly: cloudVolume.PriceMonthly, + VolumeDescription: cloudVolume.VolumeDescription, + VolumeType: cloudVolume.VolumeType, + IsDefault: pointy.Bool(cloudVolume.IsDefault), + }) + } + + return resp +} + +func findCloudImage(images []storage.CloudImage, regionName string) *storage.CloudImage { + for i, image := range images { + if image.Region == regionName { + return &images[i] + } + } + + return nil +} diff --git a/console/service/internal/convert/operations.go b/console/service/internal/convert/operations.go new file mode 100644 index 000000000..76f00bbed --- /dev/null +++ b/console/service/internal/convert/operations.go @@ -0,0 +1,36 @@ +package convert + +import ( + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + + "github.com/go-openapi/strfmt" +) + +func OperationViewToSwagger(op *storage.OperationView) *models.ResponseOperation { + return &models.ResponseOperation{ + ClusterName: op.Cluster, + Environment: op.Environment, + Finished: func() *strfmt.DateTime { + if op.Finished == nil { + return nil + } + finished := strfmt.DateTime(*op.Finished) + + return &finished + }(), + ID: op.ID, + Started: strfmt.DateTime(op.Started), + Status: op.Status, + Type: op.Type, + } +} + +func OperationsViewToSwagger(ops []storage.OperationView) []*models.ResponseOperation { + resp := make([]*models.ResponseOperation, 0, len(ops)) + for _, op := range ops { + resp = append(resp, OperationViewToSwagger(&op)) + } + + return resp +} diff --git a/console/service/internal/convert/postgres_versions.go b/console/service/internal/convert/postgres_versions.go new file mode 100644 index 000000000..afe996613 --- /dev/null +++ b/console/service/internal/convert/postgres_versions.go @@ -0,0 +1,25 @@ +package convert + +import ( + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + + "github.com/go-openapi/strfmt" +) + +func PostgresVersion(pv *storage.PostgresVersion) *models.ResponsePostgresVersion { + return &models.ResponsePostgresVersion{ + EndOfLife: strfmt.Date(pv.EndOfLife), + MajorVersion: pv.MajorVersion, + ReleaseDate: strfmt.Date(pv.ReleaseDate), + } +} + +func PostgresVersions(pvs []storage.PostgresVersion) []*models.ResponsePostgresVersion { + resp := make([]*models.ResponsePostgresVersion, 0, len(pvs)) + for _, pv := range pvs { + resp = append(resp, PostgresVersion(&pv)) + } + + return resp +} diff --git a/console/service/internal/convert/projects.go b/console/service/internal/convert/projects.go new file mode 100644 index 000000000..43812e99f --- /dev/null +++ b/console/service/internal/convert/projects.go @@ -0,0 +1,34 @@ +package convert + +import ( + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + + "github.com/go-openapi/strfmt" +) + +func ProjectToSwagger(prj *storage.Project) *models.ResponseProject { + return &models.ResponseProject{ + CreatedAt: strfmt.DateTime(prj.CreatedAt), + Description: prj.Description, + ID: prj.ID, + Name: prj.Name, + UpdatedAt: func() *strfmt.DateTime { + if prj.UpdatedAt == nil { + return nil + } + updated := strfmt.DateTime(*prj.UpdatedAt) + + return &updated + }(), + } +} + +func ProjectsToSwagger(projects []storage.Project) []*models.ResponseProject { + resp := make([]*models.ResponseProject, 0, len(projects)) + for _, prj := range projects { + resp = append(resp, ProjectToSwagger(&prj)) + } + + return resp +} diff --git a/console/service/internal/convert/secret.go b/console/service/internal/convert/secret.go new file mode 100644 index 000000000..059fda7b8 --- /dev/null +++ b/console/service/internal/convert/secret.go @@ -0,0 +1,37 @@ +package convert + +import ( + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + + "github.com/go-openapi/strfmt" +) + +func SecretViewToSwagger(secret *storage.SecretView) *models.ResponseSecretInfo { + return &models.ResponseSecretInfo{ + CreatedAt: strfmt.DateTime(secret.CreatedAt), + ID: secret.ID, + IsUsed: secret.IsUsed, + Name: secret.Name, + ProjectID: secret.ProjectID, + Type: models.SecretType(secret.Type), + UpdatedAt: func() *strfmt.DateTime { + if secret.UpdatedAt == nil { + return nil + } + updated := strfmt.DateTime(*secret.UpdatedAt) + + return &updated + }(), + UsedByClusters: secret.UsedByClusters, + } +} + +func SecretsViewToSwagger(secrets []storage.SecretView) []*models.ResponseSecretInfo { + resp := make([]*models.ResponseSecretInfo, 0, len(secrets)) + for _, sec := range secrets { + resp = append(resp, SecretViewToSwagger(&sec)) + } + + return resp +} diff --git a/console/service/internal/convert/settings.go b/console/service/internal/convert/settings.go new file mode 100644 index 000000000..5e62fe0e4 --- /dev/null +++ b/console/service/internal/convert/settings.go @@ -0,0 +1,34 @@ +package convert + +import ( + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/models" + + "github.com/go-openapi/strfmt" +) + +func SettingToSwagger(s *storage.Setting) *models.ResponseSetting { + return &models.ResponseSetting{ + CreatedAt: strfmt.DateTime(s.CreatedAt), + ID: s.ID, + Name: s.Name, + UpdatedAt: func() *strfmt.DateTime { + if s.UpdatedAt == nil { + return nil + } + updated := strfmt.DateTime(*s.UpdatedAt) + + return &updated + }(), + Value: s.Value, + } +} + +func SettingsToSwagger(settings []storage.Setting) []*models.ResponseSetting { + resp := make([]*models.ResponseSetting, 0, len(settings)) + for _, s := range settings { + resp = append(resp, SettingToSwagger(&s)) + } + + return resp +} diff --git a/console/service/internal/db/db.go b/console/service/internal/db/db.go new file mode 100644 index 000000000..30dfed93b --- /dev/null +++ b/console/service/internal/db/db.go @@ -0,0 +1,31 @@ +package db + +import ( + "context" + "fmt" + "postgresql-cluster-console/internal/configuration" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func NewDbPool(cfg *configuration.Config) (*pgxpool.Pool, error) { + connString := fmt.Sprintf("postgres://%s:%s@%s:%d/%s", + cfg.Db.User, cfg.Db.Password, cfg.Db.Host, cfg.Db.Port, cfg.Db.DbName) + poolConfig, err := pgxpool.ParseConfig(connString) + if err != nil { + return nil, err + } + //poolConfig.ConnConfig.PreferSimpleProtocol = true //(don't need simple protocol https://github.com/jackc/pgx/issues/650) + poolConfig.ConnConfig.Tracer = NewTracerZerolog() + poolConfig.MaxConns = cfg.Db.MaxConns + poolConfig.HealthCheckPeriod = time.Minute * 10 + if cfg.Db.MaxConnLifeTime != 0 { + poolConfig.MaxConnLifetime = cfg.Db.MaxConnLifeTime + } + if cfg.Db.MaxConnIdleTime != 0 { + poolConfig.MaxConnIdleTime = cfg.Db.MaxConnIdleTime + } + + return pgxpool.NewWithConfig(context.Background(), poolConfig) +} diff --git a/console/service/internal/db/tracer.go b/console/service/internal/db/tracer.go new file mode 100644 index 000000000..8389942ce --- /dev/null +++ b/console/service/internal/db/tracer.go @@ -0,0 +1,124 @@ +package db + +import ( + "context" + "encoding/hex" + "fmt" + "postgresql-cluster-console/pkg/tracer" + "strings" + "time" + + "github.com/gdex-lab/go-render/render" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +type ( + traceCtxKey struct{} + traceCtxValue struct { + startTime time.Time + queryId string + } + tracerZerolog struct{} +) + +func NewTracerZerolog() pgx.QueryTracer { + return tracerZerolog{} +} + +func (t tracerZerolog) TraceQueryStart( + ctx context.Context, + conn *pgx.Conn, + data pgx.TraceQueryStartData, +) context.Context { + now := time.Now() + queryId := uuid.New().String() + localLog := t.makeTraceLogger(ctx, queryId) + + localLog.Debug().Str("sql", strings.Map(func(r rune) rune { + switch r { + case 0x000A, 0x0009, 0x000B, 0x000C, 0x000D, 0x0085, 0x2028, 0x2029: + return -1 + default: + return r + } + }, data.SQL)).Str("args", logQueryArgs(data.Args)).Msg("TraceQueryStart") + + return context.WithValue(ctx, traceCtxKey{}, &traceCtxValue{startTime: now, queryId: queryId}) +} + +func (t tracerZerolog) TraceQueryEnd( + ctx context.Context, + conn *pgx.Conn, + data pgx.TraceQueryEndData, +) { + traceValues, ok := ctx.Value(traceCtxKey{}).(*traceCtxValue) + if !ok { + return + } + + localLog := t.makeTraceLogger(ctx, traceValues.queryId) + msg := fmt.Sprintf("TraceQueryEnd duration: %s", time.Since(traceValues.startTime)) + if data.Err != nil { + localLog.Error().Err(data.Err).Msg(msg) + } else { + localLog.Debug().Msg(msg) + } +} + +func (t tracerZerolog) makeTraceLogger(ctx context.Context, queryId string) zerolog.Logger { + cid := getCid(ctx) + logCtx := log.With().Str("query_id", queryId) + if len(cid) != 0 { + logCtx = logCtx.Str("cid", cid) + } + + return logCtx.Logger() +} + +func getCid(ctx context.Context) string { + cid, ok := ctx.Value(tracer.CtxCidKey{}).(string) + if !ok { + return uuid.New().String() + } + + return cid +} + +func logQueryArgs(args []any) string { + //logArgs := make([]string, 0, len(args)) + + paramsStr := strings.Builder{} + paramsStr.WriteString("(") + + for i, a := range args { + switch v := a.(type) { + case []byte: + if len(v) < 64 { + a = hex.EncodeToString(v) + } else { + a = fmt.Sprintf("%x (truncated %d bytes)", v[:64], len(v)-64) + } + case string: + if len(v) > 64 { + a = fmt.Sprintf("%s (truncated %d bytes)", v[:64], len(v)-64) + } + } + if i != len(args)-1 { + paramsStr.WriteString(",") + } + + if stringer, ok := a.(fmt.Stringer); ok { + paramsStr.WriteString(stringer.String()) + } else { + paramsStr.WriteString(render.Render(a)) + } + } + + paramsStr.WriteString(")") + + return paramsStr.String() +} diff --git a/console/service/internal/service/service.go b/console/service/internal/service/service.go new file mode 100644 index 000000000..51507420e --- /dev/null +++ b/console/service/internal/service/service.go @@ -0,0 +1,128 @@ +package service + +import ( + "postgresql-cluster-console/internal/configuration" + "postgresql-cluster-console/internal/controllers/cluster" + "postgresql-cluster-console/internal/controllers/dictionary" + "postgresql-cluster-console/internal/controllers/environment" + "postgresql-cluster-console/internal/controllers/operation" + "postgresql-cluster-console/internal/controllers/project" + "postgresql-cluster-console/internal/controllers/secret" + "postgresql-cluster-console/internal/controllers/setting" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/internal/watcher" + "postgresql-cluster-console/internal/xdocker" + "postgresql-cluster-console/models" + "postgresql-cluster-console/restapi" + "postgresql-cluster-console/restapi/operations" + "postgresql-cluster-console/restapi/operations/system" + + "github.com/go-openapi/runtime/middleware" + + "github.com/go-openapi/loads" + "github.com/jessevdk/go-flags" + "github.com/rs/zerolog/log" +) + +type IService interface { + Serve() error +} + +type httpService struct { + srv *restapi.Server +} + +func NewService( + cfg *configuration.Config, + version string, + db storage.IStorage, + dockerManager xdocker.IManager, + logCollector watcher.LogCollector, + clusterWatcher watcher.ClusterWatcher, +) (IService, error) { + swaggerSpec, err := loads.Analyzed(restapi.SwaggerJSON, "2.0") + if err != nil { + return nil, err + } + api := operations.NewPgConsoleAPI(swaggerSpec) + srv := restapi.NewServer(api) + + srv.Host = cfg.Http.Host + srv.Port = cfg.Http.Port + srv.ReadTimeout = cfg.Http.ReadTimeout + srv.WriteTimeout = cfg.Http.WriteTimeout + restapi.Token = cfg.Authorization.Token + + localLog := log.With().Str("module", "http_server").Logger() + api.Logger = func(s string, i ...interface{}) { + localLog.Debug().Msgf(s, i...) + } + + if cfg.Https.IsUsed { + srv.EnabledListeners = append(srv.EnabledListeners, "https") + srv.TLSHost = cfg.Https.Host + srv.TLSPort = cfg.Https.Port + srv.TLSReadTimeout = cfg.Http.ReadTimeout + srv.TLSWriteTimeout = cfg.Http.WriteTimeout + srv.TLSCACertificate = flags.Filename(cfg.Https.CACert) + srv.TLSCertificate = flags.Filename(cfg.Https.ServerCert) + srv.TLSCertificateKey = flags.Filename(cfg.Https.ServerKey) + } + + api.DictionaryGetExternalDeploymentsHandler = dictionary.NewGetExternalDeploymentsHandler(db) + api.DictionaryGetDatabaseExtensionsHandler = dictionary.NewGetDbExtensionsHandler(db) + api.DictionaryGetPostgresVersionsHandler = dictionary.NewGetPostgresVersions(db) + + // environment + api.EnvironmentGetEnvironmentsHandler = environment.NewGetEnvironmentsHandler(db) + api.EnvironmentPostEnvironmentsHandler = environment.NewPostEnvironmentsHandler(db, log.Logger) + api.EnvironmentDeleteEnvironmentsIDHandler = environment.NewDeleteEnvironmentsHandler(db, log.Logger) + + // setting + api.SettingPostSettingsHandler = setting.NewPostSettingHandler(db) + api.SettingGetSettingsHandler = setting.NewGetSettingsHandler(db) + api.SettingPatchSettingsNameHandler = setting.NewPatchSettingHandler(db) + + // project + api.ProjectPostProjectsHandler = project.NewPostProjectHandler(db, log.Logger) + api.ProjectGetProjectsHandler = project.NewGetProjectsHandler(db) + api.ProjectDeleteProjectsIDHandler = project.NewDeleteProjectHandler(db, log.Logger) + api.ProjectPatchProjectsIDHandler = project.NewPatchProjectHandler(db) + + // secret + api.SecretPostSecretsHandler = secret.NewPostSecretHandler(db, log.Logger, cfg) + api.SecretGetSecretsHandler = secret.NewGetSecretHandler(db) + api.SecretDeleteSecretsIDHandler = secret.NewDeleteSecretHandler(db) + + // cluster + api.ClusterPostClustersHandler = cluster.NewPostClusterHandler(db, dockerManager, logCollector, cfg, log.Logger) + api.ClusterDeleteClustersIDHandler = cluster.NewDeleteClusterHandler(db) + api.OperationGetOperationsHandler = operation.NewGetOperationsHandler(db) + api.OperationGetOperationsIDLogHandler = operation.NewGetOperationLogHandler(db) + api.ClusterGetClustersHandler = cluster.NewGetClustersHandler(db, log.Logger) + api.ClusterGetClustersIDHandler = cluster.NewGetClusterHandler(db, log.Logger) + api.ClusterGetClustersDefaultNameHandler = cluster.NewGetClusterDefaultNameHandler(db, log.Logger) + api.ClusterPostClustersIDRemoveHandler = cluster.NewRemoveClusterHandler(db, dockerManager, logCollector, cfg, log.Logger) + api.ClusterDeleteServersIDHandler = cluster.NewDeleteServerHandler(db, log.Logger) + api.ClusterPostClustersIDRefreshHandler = cluster.NewPostClusterRefreshHandler(db, log.Logger, clusterWatcher) + + api.SystemGetVersionHandler = system.GetVersionHandlerFunc(func(params system.GetVersionParams) middleware.Responder { + return system.NewGetVersionOK().WithPayload(&models.ResponseVersion{ + Version: version, + }) + }) + + api.Logger = func(s string, i ...interface{}) { + log.Debug().Msgf(s, i...) + } + + srv.ConfigureAPI() + + return &httpService{ + srv: srv, + }, nil +} + +func (s *httpService) Serve() error { + return s.srv.Serve() +} diff --git a/console/service/internal/storage/cluster_flags.go b/console/service/internal/storage/cluster_flags.go new file mode 100644 index 000000000..d2e824b5a --- /dev/null +++ b/console/service/internal/storage/cluster_flags.go @@ -0,0 +1,16 @@ +package storage + +import "go.openly.dev/pointy" + +const ( + patroniConnectStatusMaskSet = uint32(0x1) + patroniConnectStatusMaskRemove = uint32(0xfffffff6) +) + +func SetPatroniConnectStatus(oldMask uint32, status uint32) *uint32 { + return pointy.Uint32((oldMask & patroniConnectStatusMaskRemove) | (status & patroniConnectStatusMaskSet)) +} + +func GetPatroniConnectStatus(mask uint32) uint32 { + return mask & patroniConnectStatusMaskSet +} diff --git a/console/service/internal/storage/cluster_flags_test.go b/console/service/internal/storage/cluster_flags_test.go new file mode 100644 index 000000000..f78075ec5 --- /dev/null +++ b/console/service/internal/storage/cluster_flags_test.go @@ -0,0 +1,13 @@ +package storage + +import ( + "gotest.tools/v3/assert" + "testing" +) + +func TestClusterFlags(t *testing.T) { + assert.Equal(t, uint32(1), *SetPatroniConnectStatus(0, 1)) + assert.Equal(t, uint32(1), *SetPatroniConnectStatus(1, 1)) + assert.Equal(t, uint32(0x11), *SetPatroniConnectStatus(0x10, 1)) + assert.Equal(t, uint32(0), *SetPatroniConnectStatus(1, 0)) +} diff --git a/console/service/internal/storage/consts.go b/console/service/internal/storage/consts.go new file mode 100644 index 000000000..7d0514a19 --- /dev/null +++ b/console/service/internal/storage/consts.go @@ -0,0 +1,55 @@ +package storage + +const ( + DefaultLimit = 20 + InstanceTypeSmall = "Small Size" + InstanceTypeMedium = "Medium Size" + InstanceTypeLarge = "Large Size" + + OperationStatusInProgress = "in_progress" + OperationStatusSuccess = "success" + OperationStatusFailed = "failed" + + OperationTypeDeploy = "deploy" + + ClusterStatusFailed = "failed" + ClusterStatusHealthy = "healthy" + ClusterStatusUnhealthy = "unhealthy" + ClusterStatusDegraded = "degraded" + ClusterStatusReady = "ready" + ClusterStatusUnavailable = "unavailable" +) + +var ( + secretSortFields = map[string]string{ + "name": "secret_name", + "id": "secret_id", + "type": "secret_type", + "created_at": "created_at", + "updated_at": "updated_at", + } + + clusterSortFields = map[string]string{ + "name": "cluster_name", + "id": "cluster_id", + "created_at": "created_at", + "updated_at": "updated_at", + "environment": "environment_id", + "status": "cluster_status", + "project": "project_id", + "location": "cluster_location", + "server_count": "server_count", + "postgres_version": "postgres_version", + } + + operationSortFields = map[string]string{ + "cluster_name": "cluster", + "type": "type", + "status": "status", + "id": "id", + "created_at": "created_at", + "updated_at": "updated_at", + "cluster": "cluster", + "environment": "environment", + } +) diff --git a/console/service/internal/storage/db_storage.go b/console/service/internal/storage/db_storage.go new file mode 100644 index 000000000..9a21db2da --- /dev/null +++ b/console/service/internal/storage/db_storage.go @@ -0,0 +1,825 @@ +package storage + +import ( + "context" + "errors" + "strconv" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type dbStorage struct { + db *pgxpool.Pool +} + +func NewDbStorage(db *pgxpool.Pool) IStorage { + return &dbStorage{ + db: db, + } +} + +func (s *dbStorage) GetCloudProviders(ctx context.Context, limit, offset *int64) ([]CloudProvider, *MetaPagination, error) { + var ( + curOffset = int64(0) + curLimit = int64(DefaultLimit) + ) + if limit != nil { + curLimit = *limit + } + if offset != nil { + curOffset = *offset + } + + count, err := QueryRowToScalar[int64](ctx, s.db, "select count(*) from cloud_providers") + if err != nil { + return nil, nil, err + } + + cloudProviders, err := QueryRowsToStruct[CloudProvider](ctx, s.db, "select * from cloud_providers order by provider_name limit $1 offset $2", curLimit, curOffset) + if err != nil { + return nil, nil, err + } + + return cloudProviders, &MetaPagination{ + Limit: curLimit, + Offset: curOffset, + Count: count, + }, nil +} + +func (s *dbStorage) GetCloudProviderInfo(ctx context.Context, providerCode string) (*CloudProviderInfo, error) { + cloudInstances, err := QueryRowsToStruct[CloudInstance](ctx, s.db, "select * from cloud_instances where cloud_provider = $1 order by cpu, ram", providerCode) + if err != nil { + return nil, err + } + + cloudRegions, err := QueryRowsToStruct[CloudRegion](ctx, s.db, "select * from cloud_regions where cloud_provider = $1 order by region_name", providerCode) + if err != nil { + return nil, err + } + + cloudVolumes, err := QueryRowsToStruct[CloudVolume](ctx, s.db, "select * from cloud_volumes where cloud_provider = $1", providerCode) + if err != nil { + return nil, err + } + + cloudImages, err := QueryRowsToStruct[CloudImage](ctx, s.db, "select * from cloud_images where cloud_provider = $1", providerCode) + if err != nil { + return nil, err + } + + return &CloudProviderInfo{ + Code: providerCode, + CloudRegions: cloudRegions, + CloudInstances: cloudInstances, + CloudVolumes: cloudVolumes, + CloudImages: cloudImages, + }, nil +} + +func (s *dbStorage) GetPostgresVersions(ctx context.Context) ([]PostgresVersion, error) { + postgresVersions, err := QueryRowsToStruct[PostgresVersion](ctx, s.db, "select * from postgres_versions order by major_version") + if err != nil { + return nil, err + } + + return postgresVersions, nil +} + +func (s *dbStorage) CreateSetting(ctx context.Context, name string, value interface{}) (*Setting, error) { + setting, err := QueryRowToStruct[Setting](ctx, s.db, + `insert into settings(setting_name, setting_value) values($1, $2) returning *`, + name, value) + if err != nil { + return nil, err + } + + return setting, nil +} + +func (s *dbStorage) GetSettings(ctx context.Context, req *GetSettingsReq) ([]Setting, *MetaPagination, error) { + var ( + curOffset = int64(0) + curLimit = int64(DefaultLimit) + ) + if req.Limit != nil { + curLimit = *req.Limit + } + if req.Offset != nil { + curOffset = *req.Offset + } + + var ( + extraWhere string + extraArgsCurPosition = 1 + ) + extraArgs := []interface{}{} + { + if req.Name != nil { + extraWhere = " where setting_name = $" + strconv.Itoa(extraArgsCurPosition) + extraArgs = append(extraArgs, req.Name) + extraArgsCurPosition++ + } + } + + count, err := QueryRowToScalar[int64](ctx, s.db, "select count(*) from settings"+extraWhere, extraArgs...) + if err != nil { + return nil, nil, err + } + + limit := " limit $" + strconv.Itoa(extraArgsCurPosition) + " offset $" + strconv.Itoa(extraArgsCurPosition+1) + extraArgs = append(extraArgs, curLimit, curOffset) + + settings, err := QueryRowsToStruct[Setting](ctx, s.db, "select * from settings "+extraWhere+" order by id"+limit, extraArgs...) + if err != nil { + return nil, nil, err + } + + return settings, &MetaPagination{ + Limit: curLimit, + Offset: curOffset, + Count: count, + }, nil +} + +func (s *dbStorage) GetSettingByName(ctx context.Context, name string) (*Setting, error) { + setting, err := QueryRowToStruct[Setting](ctx, s.db, "select * from settings where setting_name = $1", name) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + + return setting, nil +} + +func (s *dbStorage) UpdateSetting(ctx context.Context, name string, value interface{}) (*Setting, error) { + setting, err := QueryRowToStruct[Setting](ctx, s.db, + `update settings set + setting_value = $1 + where setting_name = $2 + returning *`, + value, name) + if err != nil { + return nil, err + } + + return setting, nil +} + +func (s *dbStorage) CreateProject(ctx context.Context, name, description string) (*Project, error) { + project, err := QueryRowToStruct[Project](ctx, s.db, + `insert into projects(project_name, project_description) values($1, $2) returning *`, + name, description) + if err != nil { + return nil, err + } + + return project, nil +} + +func (s *dbStorage) GetProjects(ctx context.Context, limit, offset *int64) ([]Project, *MetaPagination, error) { + var ( + curOffset = int64(0) + curLimit = int64(DefaultLimit) + ) + if limit != nil { + curLimit = *limit + } + if offset != nil { + curOffset = *offset + } + + count, err := QueryRowToScalar[int64](ctx, s.db, "select count(*) from projects") + if err != nil { + return nil, nil, err + } + + projects, err := QueryRowsToStruct[Project](ctx, s.db, "select * from projects order by project_id limit $1 offset $2", curLimit, curOffset) + if err != nil { + return nil, nil, err + } + + return projects, &MetaPagination{ + Limit: curLimit, + Offset: curOffset, + Count: count, + }, nil +} + +func (s *dbStorage) GetProject(ctx context.Context, id int64) (*Project, error) { + project, err := QueryRowToStruct[Project](ctx, s.db, "select * from projects where project_id = $1", id) + if err != nil { + return nil, err + } + + return project, nil +} + +func (s *dbStorage) GetProjectByName(ctx context.Context, name string) (*Project, error) { + project, err := QueryRowToStruct[Project](ctx, s.db, "select * from projects where project_name = $1", name) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + + return project, nil +} + +func (s *dbStorage) DeleteProject(ctx context.Context, id int64) error { + _, err := s.db.Exec(ctx, "delete from projects where project_id=$1", id) + + return err +} + +func (s *dbStorage) UpdateProject(ctx context.Context, id int64, name, description *string) (*Project, error) { + project, err := QueryRowToStruct[Project](ctx, s.db, + `update projects set + project_name = coalesce($1, project_name), + project_description = coalesce($2, project_description) + where project_id = $3 + returning *`, + name, description, id) + if err != nil { + return nil, err + } + + return project, nil +} + +func (s *dbStorage) GetEnvironments(ctx context.Context, limit, offset *int64) ([]Environment, *MetaPagination, error) { + var ( + curOffset = int64(0) + curLimit = int64(DefaultLimit) + ) + if limit != nil { + curLimit = *limit + } + if offset != nil { + curOffset = *offset + } + + count, err := QueryRowToScalar[int64](ctx, s.db, "select count(*) from environments") + if err != nil { + return nil, nil, err + } + + environments, err := QueryRowsToStruct[Environment](ctx, s.db, "select * from environments order by environment_id limit $1 offset $2", curLimit, curOffset) + if err != nil { + return nil, nil, err + } + + return environments, &MetaPagination{ + Limit: curLimit, + Offset: curOffset, + Count: count, + }, nil +} + +func (s *dbStorage) GetEnvironment(ctx context.Context, id int64) (*Environment, error) { + environment, err := QueryRowToStruct[Environment](ctx, s.db, "select * from environments where environment_id = $1", id) + if err != nil { + return nil, err + } + + return environment, nil +} + +func (s *dbStorage) GetEnvironmentByName(ctx context.Context, name string) (*Environment, error) { + environment, err := QueryRowToStruct[Environment](ctx, s.db, "select * from environments where environment_name = $1", name) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + + return environment, nil +} + +func (s *dbStorage) CreateEnvironment(ctx context.Context, req *AddEnvironmentReq) (*Environment, error) { + environment, err := QueryRowToStruct[Environment](ctx, s.db, "insert into environments(environment_name, environment_description) values($1, $2) returning *", + req.Name, req.Description) + if err != nil { + return nil, err + } + + return environment, nil +} + +func (s *dbStorage) DeleteEnvironment(ctx context.Context, id int64) error { + _, err := s.db.Exec(ctx, "delete from environments where environment_id=$1", id) + + return err +} + +func (s *dbStorage) CheckEnvironmentIsUsed(ctx context.Context, id int64) (bool, error) { + count, err := QueryRowToScalar[int64](ctx, s.db, "select count(*) from clusters where environment_id = $1", id) + if err != nil { + return false, err + } + + return count != 0, nil +} + +func (s *dbStorage) GetSecrets(ctx context.Context, req *GetSecretsReq) ([]SecretView, *MetaPagination, error) { + var ( + curOffset = int64(0) + curLimit = int64(DefaultLimit) + ) + if req.Limit != nil { + curLimit = *req.Limit + } + if req.Offset != nil { + curOffset = *req.Offset + } + + var ( + extraWhere string + extraArgsCurPosition = 2 + ) + extraArgs := []interface{}{req.ProjectID} + { + if req.Name != nil { + extraWhere = " and secret_name = $" + strconv.Itoa(extraArgsCurPosition) + extraArgs = append(extraArgs, req.Name) + extraArgsCurPosition++ + } + if req.Type != nil { + extraWhere += " and secret_type = $" + strconv.Itoa(extraArgsCurPosition) + extraArgsCurPosition++ + extraArgs = append(extraArgs, req.Type) + } + } + + count, err := QueryRowToScalar[int64](ctx, s.db, "select count(*) from secrets where project_id = $1"+extraWhere, extraArgs...) + if err != nil { + return nil, nil, err + } + + orderBy := OrderByConverter(req.SortBy, "secret_id", secretSortFields) + + limit := " limit $" + strconv.Itoa(extraArgsCurPosition) + " offset $" + strconv.Itoa(extraArgsCurPosition+1) + extraArgs = append(extraArgs, curLimit, curOffset) + + secrets, err := QueryRowsToStruct[SecretView](ctx, s.db, "select * from v_secrets_list where project_id = $1 "+extraWhere+" order by "+orderBy+limit, extraArgs...) + if err != nil { + return nil, nil, err + } + + return secrets, &MetaPagination{ + Limit: curLimit, + Offset: curOffset, + Count: count, + }, nil +} + +func (s *dbStorage) GetSecret(ctx context.Context, id int64) (*SecretView, error) { + sec, err := QueryRowToStruct[SecretView](ctx, s.db, "select * from v_secrets_list where secret_id = $1", id) + if err != nil { + return nil, err + } + + return sec, nil +} + +func (s *dbStorage) GetSecretByName(ctx context.Context, name string) (*SecretView, error) { + sec, err := QueryRowToStruct[SecretView](ctx, s.db, "select * from v_secrets_list where secret_name = $1", name) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + + return sec, nil +} + +func (s *dbStorage) CreateSecret(ctx context.Context, req *AddSecretReq) (*SecretView, error) { + secretID, err := QueryRowToScalar[int64](ctx, s.db, "select * from add_secret($1, $2, $3, $4, $5)", + req.ProjectID, req.Type, req.Name, req.Value, req.SecretKey) + if err != nil { + return nil, err + } + + secret, err := QueryRowToStruct[SecretView](ctx, s.db, "select * from v_secrets_list where secret_id = $1 ", secretID) + if err != nil { + return nil, err + } + + return secret, err +} + +func (s *dbStorage) UpdateSecret(ctx context.Context, req *EditSecretReq) (*SecretView, error) { + return nil, nil +} + +func (s *dbStorage) DeleteSecret(ctx context.Context, id int64) error { + _, err := s.db.Exec(ctx, "delete from secrets where secret_id=$1", id) + + return err +} + +func (s *dbStorage) GetSecretVal(ctx context.Context, id int64, secretKey string) ([]byte, error) { + secretVal, err := QueryRowToScalar[[]byte](ctx, s.db, "select * from get_secret($1, $2)", + id, secretKey) + if err != nil { + return nil, err + } + + return secretVal, nil +} + +func (s *dbStorage) GetExtensions(ctx context.Context, req *GetExtensionsReq) ([]Extension, *MetaPagination, error) { + var ( + curOffset = int64(0) + curLimit = int64(DefaultLimit) + ) + if req.Limit != nil { + curLimit = *req.Limit + } + if req.Offset != nil { + curOffset = *req.Offset + } + + subQuery := ` WHERE (e.postgres_min_version IS NULL OR e.postgres_min_version::float <= $1) + AND (e.postgres_max_version IS NULL OR e.postgres_max_version::float >= $1) + AND ($2 = 'all' OR ($2 = 'contrib' AND e.contrib = true) OR ($2 = 'third_party' AND e.contrib = false))` + + count, err := QueryRowToScalar[int64](ctx, s.db, "select count(*) from extensions as e "+subQuery, req.PostgresVersion, req.Type) + if err != nil { + return nil, nil, err + } + + extensions, err := QueryRowsToStruct[Extension](ctx, s.db, "select * from extensions as e"+subQuery+ + "ORDER BY e.contrib, e.extension_image IS NULL, e.extension_name limit $3 offset $4", + req.PostgresVersion, req.Type, curLimit, curOffset) + if err != nil { + return nil, nil, err + } + + return extensions, &MetaPagination{ + Limit: curLimit, + Offset: curOffset, + Count: count, + }, nil +} + +func (s *dbStorage) CreateCluster(ctx context.Context, req *CreateClusterReq) (*Cluster, error) { + cluster, err := QueryRowToStruct[Cluster](ctx, s.db, `insert into clusters(project_id, environment_id, cluster_name, cluster_description, secret_id, extra_vars, cluster_status, cluster_location, server_count, postgres_version, inventory) + values($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) returning *`, req.ProjectID, req.EnvironmentID, req.Name, req.Description, req.SecretID, req.ExtraVars, req.Status, req.Location, req.ServerCount, req.PostgreSqlVersion, req.Inventory) + if err != nil { + return nil, err + } + + return cluster, nil +} + +func (s *dbStorage) UpdateCluster(ctx context.Context, req *UpdateClusterReq) (*Cluster, error) { + cluster, err := QueryRowToStruct[Cluster](ctx, s.db, + `update clusters + set connection_info = coalesce($1, connection_info), + cluster_status = coalesce($2, cluster_status), + flags = coalesce($3, flags) + where cluster_id = $4 returning *`, + req.ConnectionInfo, req.Status, req.Flags, req.ID) + if err != nil { + return nil, err + } + + return cluster, nil +} + +func (s *dbStorage) GetDefaultClusterName(ctx context.Context) (string, error) { + name, err := QueryRowToScalar[string](ctx, s.db, "select * from get_cluster_name()") + if err != nil { + return "", err + } + + return name, nil +} + +func (s *dbStorage) CreateOperation(ctx context.Context, req *CreateOperationReq) (*Operation, error) { + operation, err := QueryRowToStruct[Operation](ctx, s.db, `insert into operations(project_id, cluster_id, docker_code, operation_type, operation_status, cid) + values($1, $2, $3, $4, $5, $6) returning *`, req.ProjectID, req.ClusterID, req.DockerCode, req.Type, OperationStatusInProgress, req.Cid) + if err != nil { + return nil, err + } + + return operation, nil +} + +func (s *dbStorage) GetClusterByName(ctx context.Context, name string) (*Cluster, error) { + cluster, err := QueryRowToStruct[Cluster](ctx, s.db, "select * from clusters where cluster_name = $1", name) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + + return cluster, nil +} + +func (s *dbStorage) GetCluster(ctx context.Context, id int64) (*Cluster, error) { + cluster, err := QueryRowToStruct[Cluster](ctx, s.db, "select * from clusters where cluster_id = $1", id) + if err != nil { + return nil, err + } + + return cluster, nil +} + +func (s *dbStorage) GetClusters(ctx context.Context, req *GetClustersReq) ([]Cluster, *MetaPagination, error) { + var ( + curOffset = int64(0) + curLimit = int64(DefaultLimit) + ) + if req.Limit != nil { + curLimit = *req.Limit + } + if req.Offset != nil { + curOffset = *req.Offset + } + + var ( + extraWhere string + extraArgsCurPosition = 2 + ) + extraArgs := []interface{}{req.ProjectID} + { + if req.Name != nil { + extraWhere += " and cluster_name = $" + strconv.Itoa(extraArgsCurPosition) + extraArgs = append(extraArgs, req.Name) + extraArgsCurPosition++ + } + if req.Status != nil { + extraWhere += " and cluster_status = $" + strconv.Itoa(extraArgsCurPosition) + extraArgs = append(extraArgs, req.Status) + extraArgsCurPosition++ + } + if req.Location != nil { + extraWhere += " and cluster_location = $" + strconv.Itoa(extraArgsCurPosition) + extraArgs = append(extraArgs, req.Location) + extraArgsCurPosition++ + } + if req.EnvironmentID != nil { + extraWhere += " and environment_id = $" + strconv.Itoa(extraArgsCurPosition) + extraArgs = append(extraArgs, req.EnvironmentID) + extraArgsCurPosition++ + } + if req.ServerCount != nil { + extraWhere += " and server_count = $" + strconv.Itoa(extraArgsCurPosition) + extraArgs = append(extraArgs, req.ServerCount) + extraArgsCurPosition++ + } + if req.PostgresVersion != nil { + extraWhere += " and postgres_version = $" + strconv.Itoa(extraArgsCurPosition) + extraArgs = append(extraArgs, req.PostgresVersion) + extraArgsCurPosition++ + } + if req.CreatedAtFrom != nil { + extraWhere += " and created_at >= $" + strconv.Itoa(extraArgsCurPosition) + extraArgs = append(extraArgs, req.CreatedAtFrom) + extraArgsCurPosition++ + } + if req.CreatedAtTo != nil { + extraWhere += " and created_at <= $" + strconv.Itoa(extraArgsCurPosition) + extraArgs = append(extraArgs, req.CreatedAtTo) + extraArgsCurPosition++ + } + } + + count, err := QueryRowToScalar[int64](ctx, s.db, "select count(*) from clusters where project_id = $1 and deleted_at is null"+extraWhere, extraArgs...) + if err != nil { + return nil, nil, err + } + + orderBy := OrderByConverter(req.SortBy, "cluster_id", clusterSortFields) + + limit := " limit $" + strconv.Itoa(extraArgsCurPosition) + " offset $" + strconv.Itoa(extraArgsCurPosition+1) + extraArgs = append(extraArgs, curLimit, curOffset) + + clusters, err := QueryRowsToStruct[Cluster](ctx, s.db, "select * from clusters where project_id = $1 and deleted_at is null"+extraWhere+" order by "+orderBy+limit, + extraArgs...) + if err != nil { + return nil, nil, err + } + + return clusters, &MetaPagination{ + Limit: curLimit, + Offset: curOffset, + Count: count, + }, nil +} + +func (s *dbStorage) DeleteCluster(ctx context.Context, id int64) error { + _, err := s.db.Exec(ctx, "delete from operations where cluster_id = $1", id) + if err != nil { + return err + } + + _, err = s.db.Exec(ctx, "delete from servers where cluster_id = $1", id) + if err != nil { + return err + } + + _, err = s.db.Exec(ctx, "delete from clusters where cluster_id = $1", id) + if err != nil { + return err + } + + return nil +} + +func (s *dbStorage) DeleteClusterSoft(ctx context.Context, id int64) error { + query := ` + update clusters + set + deleted_at = current_timestamp, + secret_id = null, + cluster_name = cluster_name || '_deleted_' || to_char(current_timestamp, 'yyyymmddhh24miss') + where + cluster_id = $1 + ` + _, err := s.db.Exec(ctx, query, id) + + return err +} + +func (s *dbStorage) DeleteServer(ctx context.Context, id int64) error { + _, err := s.db.Exec(ctx, "delete from servers where server_id = $1", id) + if err != nil { + return err + } + + return nil +} + +func (s *dbStorage) GetInProgressOperations(ctx context.Context, from time.Time) ([]Operation, error) { + operations, err := QueryRowsToStruct[Operation](ctx, s.db, "select * from operations where operation_status = $1 and created_at > $2", + OperationStatusInProgress, from) + if err != nil { + return nil, err + } + + return operations, nil +} + +func (s *dbStorage) UpdateOperation(ctx context.Context, req *UpdateOperationReq) (*Operation, error) { + operation, err := QueryRowToStruct[Operation](ctx, s.db, + `update operations + set operation_status = coalesce($1, operation_status), + operation_log = case when $2::text is null then operation_log else concat(operation_log, CHR(10), $2::text) end + where id = $3 returning id, project_id, cluster_id, docker_code, cid, operation_type, operation_status, null, created_at, updated_at`, + req.Status, req.Logs, req.ID) + if err != nil { + return nil, err + } + + return operation, nil +} + +func (s *dbStorage) GetOperations(ctx context.Context, req *GetOperationsReq) ([]OperationView, *MetaPagination, error) { + var ( + curOffset = int64(0) + curLimit = int64(DefaultLimit) + ) + if req.Limit != nil { + curLimit = *req.Limit + } + if req.Offset != nil { + curOffset = *req.Offset + } + + subQuery := `WHERE project_id = $1 and started >= $2 and started <= $3` + + var ( + extraWhere string + extraArgsCurPosition = 4 + ) + extraArgs := []interface{}{req.ProjectID, req.StartedFrom, req.EndedTill} + { + if req.ClusterName != nil { + extraWhere = " and cluster = $" + strconv.Itoa(extraArgsCurPosition) + extraArgs = append(extraArgs, req.ClusterName) + extraArgsCurPosition++ + } + if req.Type != nil { + extraWhere += " and type = $" + strconv.Itoa(extraArgsCurPosition) + extraArgsCurPosition++ + extraArgs = append(extraArgs, req.Type) + } + if req.Status != nil { + extraWhere += " and status = $" + strconv.Itoa(extraArgsCurPosition) + extraArgsCurPosition++ + extraArgs = append(extraArgs, req.Status) + } + if req.Environment != nil { + extraWhere += " and environment = $" + strconv.Itoa(extraArgsCurPosition) + extraArgsCurPosition++ + extraArgs = append(extraArgs, req.Environment) + } + } + + count, err := QueryRowToScalar[int64](ctx, s.db, "select count(*) from v_operations "+subQuery+extraWhere, extraArgs...) + if err != nil { + return nil, nil, err + } + + orderBy := OrderByConverter(req.SortBy, "id DESC", operationSortFields) + + limit := " limit $" + strconv.Itoa(extraArgsCurPosition) + " offset $" + strconv.Itoa(extraArgsCurPosition+1) + extraArgs = append(extraArgs, curLimit, curOffset) + + operations, err := QueryRowsToStruct[OperationView](ctx, s.db, "select * from v_operations "+subQuery+extraWhere+ + " order by "+orderBy+limit, + extraArgs...) + if err != nil { + return nil, nil, err + } + + return operations, &MetaPagination{ + Limit: curLimit, + Offset: curOffset, + Count: count, + }, nil +} + +func (s *dbStorage) GetOperation(ctx context.Context, id int64) (*Operation, error) { + operation, err := QueryRowToStruct[Operation](ctx, s.db, "select * from operations where id = $1", id) + if err != nil { + return nil, err + } + + return operation, nil +} + +func (s *dbStorage) CreateServer(ctx context.Context, req *CreateServerReq) (*Server, error) { + server, err := QueryRowToStruct[Server](ctx, s.db, `insert into servers(cluster_id, server_name, server_location, ip_address) + values($1, $2, $3, $4) returning *`, req.ClusterID, req.ServerName, req.ServerLocation, req.IpAddress) + if err != nil { + return nil, err + } + + return server, nil +} + +func (s *dbStorage) GetServer(ctx context.Context, id int64) (*Server, error) { + server, err := QueryRowToStruct[Server](ctx, s.db, "select * from servers where server_id = $1", id) + if err != nil { + return nil, err + } + + return server, nil +} + +func (s *dbStorage) GetClusterServers(ctx context.Context, clusterID int64) ([]Server, error) { + servers, err := QueryRowsToStruct[Server](ctx, s.db, "select * from servers where cluster_id = $1", clusterID) + if err != nil { + return nil, err + } + + return servers, nil +} + +func (s *dbStorage) UpdateServer(ctx context.Context, req *UpdateServerReq) (*Server, error) { + server, err := QueryRowToStruct[Server](ctx, s.db, + `insert into servers(cluster_id, ip_address, server_name, server_role, server_status, timeline, lag, tags, pending_restart) + values($1, $2, $3, $4, $5, $6, $7, $8, $9) on conflict(cluster_id, ip_address) do update + set server_name = case when EXCLUDED.server_name = '' then servers.server_name else EXCLUDED.server_name end, + server_role = coalesce(EXCLUDED.server_role, servers.server_role), + server_status = coalesce(EXCLUDED.server_status, servers.server_status), + timeline = coalesce(EXCLUDED.timeline, servers.timeline), + lag = EXCLUDED.lag, + tags = coalesce(EXCLUDED.tags, servers.tags), + pending_restart = coalesce(EXCLUDED.pending_restart, servers.pending_restart) returning *`, + req.ClusterID, req.IpAddress, req.Name, req.Role, req.Status, req.Timeline, req.Lag, req.Tags, req.PendingRestart) + if err != nil { + return nil, err + } + + return server, nil +} + +func (s *dbStorage) ResetServer(ctx context.Context, clusterID int64, ipAddress string) (*Server, error) { + server, err := QueryRowToStruct[Server](ctx, s.db, + `update servers set + server_role = 'N/A', + server_status = 'N/A', + timeline = null, + lag = null, + tags = null where cluster_id = $1 and ip_address = $2 returning *`, + clusterID, ipAddress) + + if err != nil { + return nil, err + } + + return server, nil +} diff --git a/console/service/internal/storage/istorage.go b/console/service/internal/storage/istorage.go new file mode 100644 index 000000000..ed27adcc9 --- /dev/null +++ b/console/service/internal/storage/istorage.go @@ -0,0 +1,68 @@ +package storage + +import ( + "context" + "time" +) + +type IStorage interface { + GetCloudProviders(ctx context.Context, limit, offset *int64) ([]CloudProvider, *MetaPagination, error) + GetCloudProviderInfo(ctx context.Context, providerCode string) (*CloudProviderInfo, error) + GetExtensions(ctx context.Context, req *GetExtensionsReq) ([]Extension, *MetaPagination, error) + GetPostgresVersions(ctx context.Context) ([]PostgresVersion, error) + + // environment + GetEnvironments(ctx context.Context, limit, offset *int64) ([]Environment, *MetaPagination, error) + GetEnvironment(ctx context.Context, id int64) (*Environment, error) + GetEnvironmentByName(ctx context.Context, name string) (*Environment, error) + CreateEnvironment(ctx context.Context, req *AddEnvironmentReq) (*Environment, error) + DeleteEnvironment(ctx context.Context, id int64) error + CheckEnvironmentIsUsed(ctx context.Context, id int64) (bool, error) + + // setting + CreateSetting(ctx context.Context, name string, value interface{}) (*Setting, error) + GetSettings(ctx context.Context, req *GetSettingsReq) ([]Setting, *MetaPagination, error) + GetSettingByName(ctx context.Context, name string) (*Setting, error) + UpdateSetting(ctx context.Context, name string, value interface{}) (*Setting, error) + + // project + CreateProject(ctx context.Context, name, description string) (*Project, error) + GetProjects(ctx context.Context, limit, offset *int64) ([]Project, *MetaPagination, error) + GetProject(ctx context.Context, id int64) (*Project, error) + GetProjectByName(ctx context.Context, name string) (*Project, error) + DeleteProject(ctx context.Context, id int64) error + UpdateProject(ctx context.Context, id int64, name, description *string) (*Project, error) + + // secrets + GetSecrets(ctx context.Context, req *GetSecretsReq) ([]SecretView, *MetaPagination, error) + GetSecret(ctx context.Context, id int64) (*SecretView, error) + GetSecretByName(ctx context.Context, name string) (*SecretView, error) + CreateSecret(ctx context.Context, req *AddSecretReq) (*SecretView, error) + DeleteSecret(ctx context.Context, id int64) error + GetSecretVal(ctx context.Context, id int64, secretKey string) ([]byte, error) + + // cluster + CreateCluster(ctx context.Context, req *CreateClusterReq) (*Cluster, error) + GetCluster(ctx context.Context, id int64) (*Cluster, error) + GetClusters(ctx context.Context, req *GetClustersReq) ([]Cluster, *MetaPagination, error) + GetDefaultClusterName(ctx context.Context) (string, error) + DeleteCluster(ctx context.Context, id int64) error + DeleteClusterSoft(ctx context.Context, id int64) error + DeleteServer(ctx context.Context, id int64) error + GetClusterByName(ctx context.Context, name string) (*Cluster, error) + UpdateCluster(ctx context.Context, req *UpdateClusterReq) (*Cluster, error) + + // operation + CreateOperation(ctx context.Context, req *CreateOperationReq) (*Operation, error) + GetOperations(ctx context.Context, req *GetOperationsReq) ([]OperationView, *MetaPagination, error) + GetOperation(ctx context.Context, id int64) (*Operation, error) + UpdateOperation(ctx context.Context, req *UpdateOperationReq) (*Operation, error) + GetInProgressOperations(ctx context.Context, from time.Time) ([]Operation, error) + + // server + CreateServer(ctx context.Context, req *CreateServerReq) (*Server, error) + GetServer(ctx context.Context, id int64) (*Server, error) + GetClusterServers(ctx context.Context, clusterID int64) ([]Server, error) + UpdateServer(ctx context.Context, req *UpdateServerReq) (*Server, error) + ResetServer(ctx context.Context, clusterID int64, ipAddress string) (*Server, error) +} diff --git a/console/service/internal/storage/models.go b/console/service/internal/storage/models.go new file mode 100644 index 000000000..be8e0673f --- /dev/null +++ b/console/service/internal/storage/models.go @@ -0,0 +1,311 @@ +package storage + +import ( + "net" + "time" +) + +type CloudProvider struct { + Code string + Description string + ProviderImage string +} + +type CloudRegion struct { + ProviderCode string + RegionGroup string + RegionName string + Description string +} + +type CloudInstance struct { + ProviderCode string + InstanceGroup string + InstanceName string + Arch string + Cpu int64 + Ram int64 + PriceHourly float64 + PriceMonthly float64 + Currency string + UpdatedAt time.Time +} + +type CloudImage struct { + ProviderCode string + Region string + Image interface{} + Arch string + OsName string + OsVersion string + UpdatedAt time.Time +} + +type CloudVolume struct { + ProviderCode string + VolumeType string + VolumeDescription string + VolumeMinSize int64 + VolumeMaxSize int64 + PriceMonthly float64 + Currency string + IsDefault bool + UpdatedAt time.Time +} + +type CloudProviderInfo struct { + Code string + CloudRegions []CloudRegion + CloudInstances []CloudInstance + CloudVolumes []CloudVolume + CloudImages []CloudImage +} + +type PostgresVersion struct { + MajorVersion int64 + ReleaseDate time.Time + EndOfLife time.Time +} + +type Setting struct { + ID int64 + Name string + Value interface{} + CreatedAt time.Time + UpdatedAt *time.Time +} + +type GetSettingsReq struct { + Name *string + + Limit *int64 + Offset *int64 +} + +type MetaPagination struct { + Limit int64 + Offset int64 + Count int64 +} + +type Project struct { + ID int64 + Name string + Description *string + CreatedAt time.Time + UpdatedAt *time.Time +} + +type Environment struct { + ID int64 + Name string + Description *string + CreatedAt time.Time + UpdatedAt *time.Time +} + +type AddEnvironmentReq struct { + Name string + Description string +} + +type SecretView struct { + ProjectID int64 + ID int64 + Name string + Type string + CreatedAt time.Time + UpdatedAt *time.Time + IsUsed bool + UsedByClusters *string +} + +type GetSecretsReq struct { + ProjectID int64 + Name *string + Type *string + SortBy *string + + Limit *int64 + Offset *int64 +} + +type AddSecretReq struct { + ProjectID int64 + Type string + Name string + Value []byte + SecretKey string +} + +type EditSecretReq struct { + ProjectID int64 + Type *string + Name *string + Value []byte + SecretKey string +} + +type Extension struct { + Name string + Description *string + Url *string + Image *string + PostgresMinVersion *string + PostgresMaxVersion *string + Contrib bool +} + +type GetExtensionsReq struct { + Type *string + PostgresVersion *string + + Limit *int64 + Offset *int64 +} + +type Cluster struct { + ID int64 + ProjectID int64 + EnvironmentID int64 + SecretID *int64 + Name string + Status string + Description string + Location *string + ConnectionInfo interface{} + ExtraVars []byte + Inventory []byte + ServersCount int32 + PostgreVersion int32 + CreatedAt time.Time + UpdatedAt *time.Time + DeletedAt *time.Time + Flags uint32 +} + +type GetClustersReq struct { + ProjectID int64 + Name *string + SortBy *string + Status *string + Location *string + ServerCount *int64 + PostgresVersion *int64 + EnvironmentID *int64 + CreatedAtFrom *time.Time + CreatedAtTo *time.Time + + Limit *int64 + Offset *int64 +} + +type CreateClusterReq struct { + ProjectID int64 + EnvironmentID int64 + Name string + Description string + SecretID *int64 + ExtraVars []string + Location string + ServerCount int + PostgreSqlVersion int + Status string + Inventory []byte +} + +type UpdateClusterReq struct { + ID int64 + ConnectionInfo interface{} + Status *string + Flags *uint32 +} + +type Operation struct { + ID int64 + ProjectID int64 + ClusterID int64 + DockerCode string + Cid string + Type string + Status string + Log *string + CreatedAt time.Time + UpdatedAt *time.Time +} + +type OperationView struct { + ProjectID int64 + ClusterID int64 + ID int64 + Started time.Time + Finished *time.Time + Type string + Status string + Cluster string + Environment string +} + +type CreateOperationReq struct { + ProjectID int64 + ClusterID int64 + DockerCode string + Type string + Cid string +} + +type UpdateOperationReq struct { + ID int64 + Status *string + Logs *string +} + +type GetOperationsReq struct { + ProjectID int64 + StartedFrom time.Time + EndedTill time.Time + ClusterName *string + Type *string + Status *string + Environment *string + SortBy *string + + Limit *int64 + Offset *int64 +} + +type Server struct { + ID int64 + ClusterID int64 + Name string + Location *string + Role string + Status string + IpAddress net.IP + Timeline *int64 + Lag *int64 + Tags interface{} + PendingRestart *bool + CreatedAt time.Time + UpdatedAt *time.Time +} + +type CreateServerReq struct { + ClusterID int64 + ServerName string + ServerLocation *string + IpAddress string +} + +type UpdateServerReq struct { + ClusterID int64 + IpAddress string + + Name string + Role *string + Status *string + Timeline *int64 + Lag *int64 + Tags interface{} + PendingRestart *bool +} diff --git a/console/service/internal/storage/utils.go b/console/service/internal/storage/utils.go new file mode 100644 index 000000000..36796700b --- /dev/null +++ b/console/service/internal/storage/utils.go @@ -0,0 +1,114 @@ +package storage + +import ( + "context" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "strings" +) + +func QueryRowsToStruct[output any]( + ctx context.Context, + pool *pgxpool.Pool, + query string, + args ...any, +) ([]output, error) { + rows, err := pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + + return pgx.CollectRows(rows, pgx.RowToStructByPos[output]) +} + +func QueryRowsToAddrStruct[output any]( + ctx context.Context, + pool *pgxpool.Pool, + query string, + args ...any, +) ([]*output, error) { + rows, err := pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + + return pgx.CollectRows(rows, pgx.RowToAddrOfStructByPos[output]) +} + +func QueryRowToStruct[output any]( + ctx context.Context, + pool *pgxpool.Pool, + query string, + args ...any, +) (*output, error) { + rows, err := pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + + var res output + res, err = pgx.CollectOneRow(rows, pgx.RowToStructByPos[output]) + if err != nil { + return nil, err + } + + return &res, nil +} + +func QueryRowToScalar[scalar any]( + ctx context.Context, + pool *pgxpool.Pool, + query string, + args ...any, +) (scalar, error) { + rows, err := pool.Query(ctx, query, args...) + if err != nil { + var value scalar + return value, err + } + + return pgx.CollectOneRow(rows, pgx.RowTo[scalar]) +} + +func QueryRowToScalarAddr[scalar any]( + ctx context.Context, + pool *pgxpool.Pool, + query string, + args ...any, +) (*scalar, error) { + rows, err := pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + + return pgx.CollectOneRow(rows, pgx.RowToAddrOf[scalar]) +} + +func OrderByConverter(sortByFromApi *string, defaultField string, convMap map[string]string) string { + orderBy := strings.Builder{} + if sortByFromApi != nil { + sortByFields := strings.Split(*sortByFromApi, ",") + for _, sortBy := range sortByFields { + if len(sortBy) == 0 { + continue + } + order := "ASC" + if sortBy[0] == '-' { + order = "DESC" + sortBy = sortBy[1:] + } + tableField := convMap[sortBy] + if len(tableField) != 0 { + if orderBy.Len() != 0 { + orderBy.WriteString(",") + } + orderBy.WriteString(tableField + " " + order) + } + } + } + if orderBy.Len() == 0 { + return defaultField + } + + return orderBy.String() +} diff --git a/console/service/internal/watcher/cluster_watcher.go b/console/service/internal/watcher/cluster_watcher.go new file mode 100644 index 000000000..bbfcc6e24 --- /dev/null +++ b/console/service/internal/watcher/cluster_watcher.go @@ -0,0 +1,292 @@ +package watcher + +import ( + "context" + "postgresql-cluster-console/internal/configuration" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/pkg/patroni" + "postgresql-cluster-console/pkg/tracer" + "sync" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "go.openly.dev/pointy" + "golang.org/x/sync/semaphore" +) + +type ClusterWatcher interface { + Run() + Stop() + HandleCluster(ctx context.Context, cl *storage.Cluster) +} + +type clusterWatcher struct { + db storage.IStorage + isRun bool + log zerolog.Logger + cfg *configuration.Config + patroniCli patroni.IClient + + ctx context.Context + done context.CancelFunc + wg sync.WaitGroup +} + +func NewServerWatcher(db storage.IStorage, patroniCli patroni.IClient, cfg *configuration.Config) ClusterWatcher { + return &clusterWatcher{ + db: db, + cfg: cfg, + patroniCli: patroniCli, + log: log.Logger.With().Str("module", "cluster_watcher").Logger(), + } +} + +func (sw *clusterWatcher) Run() { + if sw.isRun { + return + } + sw.isRun = true + + sw.ctx, sw.done = context.WithCancel(context.Background()) + sw.wg.Add(1) + go func() { + sw.loop() + sw.wg.Done() + }() + sw.log.Info().Msg("run") +} + +func (sw *clusterWatcher) Stop() { + sw.log.Info().Msg("stopping") + sw.done() + sw.wg.Wait() + sw.isRun = false + sw.log.Info().Msg("stopped") +} + +func (sw *clusterWatcher) loop() { + timer := time.NewTimer(sw.cfg.ClusterWatcher.RunEvery) + defer timer.Stop() + + for { + select { + case <-sw.ctx.Done(): + sw.log.Info().Msg("loop is done") + + return + case <-timer.C: + sw.doWork() + timer.Reset(sw.cfg.ClusterWatcher.RunEvery) + } + } +} + +func (sw *clusterWatcher) doWork() { + sw.log.Trace().Msg("doWork started") + defer sw.log.Trace().Msg("doWork was done") + ctx := context.WithValue(sw.ctx, tracer.CtxCidKey{}, uuid.New().String()) + projects, _, err := sw.db.GetProjects(ctx, pointy.Int64(1000), pointy.Int64(0)) + if err != nil { + sw.log.Error().Err(err).Msg("failed to get projects") + + return + } + sem := semaphore.NewWeighted(sw.cfg.ClusterWatcher.PoolSize) + for _, pr := range projects { + sw.handleProject(ctx, &pr, sem) + } + _ = sem.Acquire(ctx, sw.cfg.ClusterWatcher.PoolSize) // wait all workers done +} + +func (sw *clusterWatcher) handleProject(ctx context.Context, pr *storage.Project, sem *semaphore.Weighted) { + localLog := sw.log.With().Str("project", pr.Name).Logger() + localLog.Trace().Msg("started to handler project") + defer log.Trace().Msg("project was handled") + + var ( + offset = int64(0) + limit = int64(100) // handle by 100 clusters per call + ) + for { + if ctx.Err() != nil { + return + } + + clusters, _, err := sw.db.GetClusters(ctx, &storage.GetClustersReq{ + ProjectID: pr.ID, + Limit: &limit, + Offset: &offset, + }) + if err != nil { + localLog.Error().Err(err).Msg("failed to get clusters") + + continue + } + if len(clusters) == 0 { + localLog.Trace().Msg("all clusters were handled") + + return + } + + for _, cl := range clusters { + err = sem.Acquire(ctx, 1) + if err != nil { + localLog.Error().Err(err).Msg("failed to acquire semaphore") + + return + } + cl := cl // copy for async handling + go func() { + sw.HandleCluster(ctx, &cl) + sem.Release(1) + }() + } + offset += limit + } +} + +func (sw *clusterWatcher) HandleCluster(ctx context.Context, cl *storage.Cluster) { + localLog := sw.log.With().Str("cluster", cl.Name).Logger() + cid, ok := ctx.Value(tracer.CtxCidKey{}).(string) + if ok { + localLog.With().Str("cid", cid).Logger() + } + localLog.Trace().Msg("started to handle cluster") + defer localLog.Trace().Msg("cluster was handled") + + servers, err := sw.db.GetClusterServers(ctx, cl.ID) + if err != nil { + localLog.Error().Err(err).Msg("failed to get servers by cluster") + + return + } + + sw.handleClusterServers(ctx, cl, servers) +} + +func (sw *clusterWatcher) handleClusterServers(ctx context.Context, cl *storage.Cluster, clusterServers []storage.Server) { + localLog := sw.log.With().Str("cluster", cl.Name).Logger() + cid, ok := ctx.Value(tracer.CtxCidKey{}).(string) + if ok { + localLog.With().Str("cid", cid).Logger() + } + localLog.Trace().Msg("started to handle cluster servers") + defer localLog.Trace().Msg("cluster servers were handled") + + // map with old cluster topology + serversMap := make(map[string]bool) + for _, s := range clusterServers { + serversMap[s.IpAddress.String()] = false + } + + patroniHealthCheck := false + for _, s := range clusterServers { + if ctx.Err() != nil { + return + } + + clusterInfo, err := sw.patroniCli.GetClusterInfo(ctx, s.IpAddress.String()) + if err != nil { + localLog.Debug().Err(err).Msg("failed to get patroni info") + + continue + } + localLog.Trace().Any("cluster_info", &clusterInfo).Msg("got cluster info") + patroniHealthCheck = true + + const ( + stateRunning = "running" + stateStreaming = "streaming" + ) + healthyServers := int32(0) + + for _, serverInfo := range clusterInfo.Members { + var lag *int64 + switch l := serverInfo.Lag.(type) { + case int64: + lag = &l + case uint64: + lag = pointy.Int64(int64(l)) + case int8: + lag = pointy.Int64(int64(l)) + case uint8: + lag = pointy.Int64(int64(l)) + case int16: + lag = pointy.Int64(int64(l)) + case uint16: + lag = pointy.Int64(int64(l)) + case int: + lag = pointy.Int64(int64(l)) + case uint: + lag = pointy.Int64(int64(l)) + case int32: + lag = pointy.Int64(int64(l)) + case uint32: + lag = pointy.Int64(int64(l)) + case float64: + lag = pointy.Int64(int64(l)) + default: + localLog.Trace().Type("lag_type", l).Msg("unknown lag type") + } + updatedServer, err := sw.db.UpdateServer(ctx, &storage.UpdateServerReq{ + ClusterID: cl.ID, + IpAddress: serverInfo.Host, + Name: serverInfo.Name, + Role: &serverInfo.Role, + Status: &serverInfo.State, + Timeline: &serverInfo.Timeline, + Lag: lag, + Tags: &serverInfo.Tags, + PendingRestart: &serverInfo.PendingRestart, + }) + if err != nil { + localLog.Error().Err(err).Msg("failed to update server") + } else { + localLog.Trace().Any("server", updatedServer).Msg("server was updated") + serversMap[serverInfo.Host] = true + } + if serverInfo.State == stateRunning || serverInfo.State == stateStreaming { + healthyServers++ + } + } + var status string + if len(clusterInfo.Members) < int(cl.ServersCount) { + status = storage.ClusterStatusDegraded + } else if healthyServers < cl.ServersCount { + status = storage.ClusterStatusUnhealthy + } else { + status = storage.ClusterStatusHealthy + } + _, err = sw.db.UpdateCluster(ctx, &storage.UpdateClusterReq{ + ID: cl.ID, + Status: &status, + Flags: storage.SetPatroniConnectStatus(cl.Flags, 1), + }) + if err != nil { + localLog.Error().Err(err).Msg("failed to update cluster status") + } + break + } + if !patroniHealthCheck && storage.GetPatroniConnectStatus(cl.Flags) == 1 { + _, err := sw.db.UpdateCluster(ctx, &storage.UpdateClusterReq{ + ID: cl.ID, + Status: pointy.String(storage.ClusterStatusUnavailable), + }) + if err != nil { + localLog.Error().Err(err).Msg("failed to update cluster status") + } + } + + for ipAddress, updated := range serversMap { + if !updated { + updatedServer, err := sw.db.ResetServer(ctx, cl.ID, ipAddress) + if err != nil { + localLog.Error().Err(err).Msg("failed to update unknown server") + } else { + localLog.Trace().Any("server", updatedServer).Msg("unknown server was updated") + } + } + } +} diff --git a/console/service/internal/watcher/consts.go b/console/service/internal/watcher/consts.go new file mode 100644 index 000000000..10f7b03d8 --- /dev/null +++ b/console/service/internal/watcher/consts.go @@ -0,0 +1,10 @@ +package watcher + +const ( + ContainerStatusExited = "exited" + ContainerStatusRemoving = "removing" + ContainerStatusDead = "dead" + + LogFieldSystemInfo = "System info" + LogFieldConnectionInfo = "deploy-finish : Connection info" +) diff --git a/console/service/internal/watcher/log_collector.go b/console/service/internal/watcher/log_collector.go new file mode 100644 index 000000000..14c0bbb2b --- /dev/null +++ b/console/service/internal/watcher/log_collector.go @@ -0,0 +1,94 @@ +package watcher + +import ( + "context" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/internal/xdocker" + "postgresql-cluster-console/pkg/tracer" + "sync" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +type LogCollector interface { + StoreInDb(operationID int64, dockerCode xdocker.InstanceID, cid string) + PrintToConsole(dockerCode xdocker.InstanceID, cid string) + Stop() +} + +type logCollector struct { + db storage.IStorage + dockerManager xdocker.IManager + isRun bool + log zerolog.Logger + + ctx context.Context + done context.CancelFunc + wg sync.WaitGroup +} + +func NewLogCollector(db storage.IStorage, dockerManager xdocker.IManager) LogCollector { + lc := &logCollector{ + db: db, + dockerManager: dockerManager, + log: log.Logger.With().Str("module", "log_collector").Logger(), + } + lc.ctx, lc.done = context.WithCancel(context.Background()) + + return lc +} + +func (lc *logCollector) StoreInDb(operationID int64, dockerCode xdocker.InstanceID, cid string) { + lc.wg.Add(1) + go func() { + lc.log.Debug().Str("cid", cid).Int64("operation_id", operationID).Msg("log collector started") + lc.storeLogsFromContainer(operationID, dockerCode, cid) + defer func() { + lc.wg.Done() + lc.log.Debug().Str("cid", cid).Int64("operation_id", operationID).Msg("finished") + }() + }() +} + +func (lc *logCollector) PrintToConsole(dockerCode xdocker.InstanceID, cid string) { + lc.wg.Add(1) + go func() { + lc.log.Debug().Str("cid", cid).Msg("log collector started") + lc.printLogsFromContainer(dockerCode, cid) + defer func() { + lc.wg.Done() + lc.log.Debug().Str("cid", cid).Msg("finished") + }() + }() +} + +func (lc *logCollector) Stop() { + lc.log.Info().Msg("stopping") + lc.done() + lc.wg.Wait() + lc.log.Info().Msg("stopped") +} + +func (lc *logCollector) storeLogsFromContainer(operationID int64, dockerCode xdocker.InstanceID, cid string) { + ctx := context.WithValue(lc.ctx, tracer.CtxCidKey{}, cid) + lc.log.Trace().Msg("storeLogsFromContainer called") + lc.dockerManager.StoreContainerLogs(ctx, dockerCode, func(logMessage string) { + lc.log.Trace().Str("cid", cid).Str("proc", "storeLogsFromContainer").Msg(logMessage) + _, err := lc.db.UpdateOperation(ctx, &storage.UpdateOperationReq{ + ID: operationID, + Logs: &logMessage, + }) + if err != nil { + lc.log.Error().Err(err).Int64("operation_id", operationID).Msg("failed to update log") + } + }) +} + +func (lc *logCollector) printLogsFromContainer(dockerCode xdocker.InstanceID, cid string) { + ctx := context.WithValue(lc.ctx, tracer.CtxCidKey{}, cid) + lc.log.Trace().Msg("storeLogsFromContainer called") + lc.dockerManager.StoreContainerLogs(ctx, dockerCode, func(logMessage string) { + lc.log.Trace().Str("cid", cid).Msg(logMessage) + }) +} diff --git a/console/service/internal/watcher/log_watcher.go b/console/service/internal/watcher/log_watcher.go new file mode 100644 index 000000000..7827a1c0d --- /dev/null +++ b/console/service/internal/watcher/log_watcher.go @@ -0,0 +1,210 @@ +package watcher + +import ( + "context" + "encoding/json" + "os" + "postgresql-cluster-console/internal/configuration" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/internal/xdocker" + "postgresql-cluster-console/pkg/tracer" + "sync" + "time" + + "github.com/mitchellh/mapstructure" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +type LogWatcher interface { + Run() + Stop() +} + +type logWatcher struct { + db storage.IStorage + dockerManager xdocker.IManager + isRun bool + log zerolog.Logger + cfg *configuration.Config + + ctx context.Context + done context.CancelFunc + wg sync.WaitGroup +} + +func NewLogWatcher(db storage.IStorage, dockerManager xdocker.IManager, cfg *configuration.Config) LogWatcher { + return &logWatcher{ + db: db, + dockerManager: dockerManager, + cfg: cfg, + log: log.Logger.With().Str("module", "log_watcher").Logger(), + } +} + +func (lw *logWatcher) Run() { + if lw.isRun { + return + } + lw.isRun = true + + lw.ctx, lw.done = context.WithCancel(context.Background()) + lw.wg.Add(1) + go func() { + lw.loop() + lw.wg.Done() + }() + lw.log.Info().Msg("run") +} + +func (lw *logWatcher) Stop() { + lw.log.Info().Msg("stopping") + lw.done() + lw.wg.Wait() + lw.isRun = false + lw.log.Info().Msg("stopped") +} + +func (lw *logWatcher) loop() { + timer := time.NewTimer(lw.cfg.LogWatcher.RunEvery) + defer timer.Stop() + + for { + select { + case <-lw.ctx.Done(): + lw.log.Info().Msg("loop is done") + + return + case <-timer.C: + lw.doWork() + timer.Reset(lw.cfg.LogWatcher.RunEvery) + } + } +} + +func (lw *logWatcher) doWork() { + lw.log.Debug().Msg("starting to collect info about operations performed on clusters") + operations, err := lw.db.GetInProgressOperations(lw.ctx, time.Now().Add(-lw.cfg.LogWatcher.AnalyzePast)) + if err != nil { + lw.log.Error().Err(err).Msg("failed to get in_progress operations") + + return + } + for _, op := range operations { + localLog := lw.log.With().Str("cid", op.Cid).Int64("operation_id", op.ID).Logger() + localLog.Trace().Msg("starting to collect info") + + opCtx := context.WithValue(lw.ctx, tracer.CtxCidKey{}, op.Cid) + containerStatus, err := lw.dockerManager.GetStatus(opCtx, xdocker.InstanceID(op.DockerCode)) + if err != nil { + localLog.Error().Err(err).Msg("failed to get containers status") + continue + } + localLog.Trace().Str("container_status", containerStatus).Msg("got container status") + switch containerStatus { + case ContainerStatusExited, ContainerStatusDead, ContainerStatusRemoving: + lw.collectContainerLog(opCtx, &op, localLog) + err = lw.dockerManager.RemoveContainer(opCtx, xdocker.InstanceID(op.DockerCode)) + if err != nil { + localLog.Error().Err(err).Msg("failed to remove container") + } + default: + localLog.Trace().Msg("skipped") + } + } +} + +func (lw *logWatcher) collectContainerLog(ctx context.Context, op *storage.Operation, log zerolog.Logger) { + clusterInfo, err := lw.db.GetCluster(ctx, op.ID) + if err != nil { + log.Error().Err(err).Msg("failed to get cluster from db") + + return + } + + fileLog := lw.cfg.Docker.LogDir + "/" + clusterInfo.Name + ".json" + fLog, err := os.Open(fileLog) + if err != nil { + log.Error().Err(err).Str("file_name", fileLog).Msg("can't open file with log") + + return + } + + var logs []LogEntity + jsonDec := json.NewDecoder(fLog) + err = jsonDec.Decode(&logs) + if err != nil { + log.Error().Err(err).Msg("failed to decode file log") + + return + } + + var status string + for _, logEntity := range logs { + switch logEntity.Task { + case LogFieldSystemInfo: + var serverInfo SystemInfo + err = mapstructure.Decode(logEntity.Msg, &serverInfo) + if err != nil { + log.Error().Err(err).Any("msg", logEntity.Msg).Msg("failed to decode system_info") + continue + } + + createdServer, err := lw.db.CreateServer(ctx, &storage.CreateServerReq{ + ClusterID: clusterInfo.ID, + ServerName: serverInfo.ServerName, + ServerLocation: serverInfo.ServerLocation, + IpAddress: serverInfo.IpAddress, + }) + if err != nil { + log.Error().Err(err).Msg("failed to store server to db") + + continue + } + log.Trace().Any("server", createdServer).Msg("server was created") + case LogFieldConnectionInfo: + _, err := lw.db.UpdateCluster(ctx, &storage.UpdateClusterReq{ + ID: op.ClusterID, + ConnectionInfo: logEntity.Msg, + }) + if err != nil { + log.Error().Err(err).Msg("failed to update cluster") + + continue + } + } + if logEntity.Summary != nil { + status = logEntity.Status + } + } + if len(status) == 0 { + log.Warn().Msg("summary not found in logs") + + status = storage.OperationStatusFailed + } + updatedOperation, err := lw.db.UpdateOperation(ctx, &storage.UpdateOperationReq{ + ID: op.ID, + Status: &status, + }) + if err != nil { + log.Error().Err(err).Msg("failed to update operation status in db") + } else { + log.Trace().Any("operation", updatedOperation).Msg("operation was updated in db") + } + + // set cluster status + if status == storage.OperationStatusFailed { + status = storage.ClusterStatusFailed + } else { + status = storage.ClusterStatusReady + } + updatedCluster, err := lw.db.UpdateCluster(ctx, &storage.UpdateClusterReq{ + ID: op.ClusterID, + Status: &status, + }) + if err != nil { + log.Error().Err(err).Msg("failed to update cluster status in db") + } else { + log.Trace().Any("cluster", updatedCluster).Msg("cluster was updated in db") + } +} diff --git a/console/service/internal/watcher/models.go b/console/service/internal/watcher/models.go new file mode 100644 index 000000000..e20cce630 --- /dev/null +++ b/console/service/internal/watcher/models.go @@ -0,0 +1,15 @@ +package watcher + +type LogEntity struct { + Task string `json:"task"` + Failed bool `json:"failed"` + Msg interface{} `json:"msg"` + Summary interface{} `json:"summary,omitempty"` + Status string `json:"status"` +} + +type SystemInfo struct { + ServerLocation *string `json:"server_location,omitempty" mapstructure:"server_location"` + ServerName string `json:"server_name" mapstructure:"server_name"` + IpAddress string `json:"ip_address" mapstructure:"ip_address"` +} diff --git a/console/service/internal/xdocker/images.go b/console/service/internal/xdocker/images.go new file mode 100644 index 000000000..0ab09496c --- /dev/null +++ b/console/service/internal/xdocker/images.go @@ -0,0 +1,7 @@ +package xdocker + +const ( + playbookCreateCluster = "deploy_pgcluster.yml" + + entryPoint = "ansible-playbook" +) diff --git a/console/service/internal/xdocker/imanager.go b/console/service/internal/xdocker/imanager.go new file mode 100644 index 000000000..0c609e0ea --- /dev/null +++ b/console/service/internal/xdocker/imanager.go @@ -0,0 +1,23 @@ +package xdocker + +import "context" + +type InstanceID string +type ManageClusterConfig struct { + Envs []string + ExtraVars []string + Mounts []Mount +} + +type Mount struct { + DockerPath string + HostPath string +} + +type IManager interface { + ManageCluster(ctx context.Context, req *ManageClusterConfig) (InstanceID, error) + GetStatus(ctx context.Context, id InstanceID) (string, error) + StoreContainerLogs(ctx context.Context, id InstanceID, store func(logMessage string)) + PreloadImage(ctx context.Context) + RemoveContainer(ctx context.Context, id InstanceID) error +} diff --git a/console/service/internal/xdocker/manager.go b/console/service/internal/xdocker/manager.go new file mode 100644 index 000000000..470a06fc8 --- /dev/null +++ b/console/service/internal/xdocker/manager.go @@ -0,0 +1,159 @@ +package xdocker + +import ( + "bufio" + "context" + "net/http" + "postgresql-cluster-console/pkg/tracer" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/client" + "github.com/goombaio/namegenerator" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +type dockerManager struct { + cli *client.Client + log zerolog.Logger + image string +} + +func NewDockerManager(host string, image string) (IManager, error) { + var rt http.RoundTripper + rt, err := NewRoundTripperLog(host, log.Logger.With().Str("module", "docker_client").Logger()) + if err != nil { + return nil, err + } + cli, err := client.NewClientWithOpts( + client.WithHost(host), + client.WithHTTPClient(&http.Client{ + Transport: rt, + }), + client.WithAPIVersionNegotiation()) + if err != nil { + return nil, err + } + + return &dockerManager{ + cli: cli, + log: log.Logger.With().Str("module", "docker_manager").Logger(), + image: image, + }, nil +} + +func (m *dockerManager) ManageCluster(ctx context.Context, config *ManageClusterConfig) (InstanceID, error) { + localLog := m.log.With().Str("cid", ctx.Value(tracer.CtxCidKey{}).(string)).Logger() + err := m.pullImage(ctx, m.image) + if err != nil { + return "", err + } + + resp, err := m.cli.ContainerCreate(ctx, + &container.Config{ + Image: m.image, + Tty: true, + Env: config.Envs, + Cmd: func() []string { + cmd := []string{entryPoint, playbookCreateCluster} + for _, vars := range config.ExtraVars { + cmd = append(cmd, "--extra-vars", vars) + } + + return cmd + }(), + Entrypoint: nil, + }, &container.HostConfig{ + NetworkMode: "host", + Mounts: func() []mount.Mount { + var mounts []mount.Mount + for _, mountPath := range config.Mounts { + mounts = append(mounts, mount.Mount{ + Type: "bind", + Source: mountPath.HostPath, + Target: mountPath.DockerPath, + }) + } + + return mounts + }(), + }, nil, nil, namegenerator.NewNameGenerator(time.Now().UTC().UnixNano()).Generate()) + + if err != nil { + return "", err + } + + localLog.Trace().Str("id", resp.ID).Msg("container was created") + if len(resp.Warnings) != 0 { + localLog.Warn().Strs("warnings", resp.Warnings).Msg("warnings during container creation") + } + + err = m.cli.ContainerStart(ctx, resp.ID, container.StartOptions{}) + 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") + } + + return "", err + } + + return InstanceID(resp.ID), nil +} + +func (m *dockerManager) PreloadImage(ctx context.Context) { + _ = m.pullImage(ctx, m.image) +} + +func (m *dockerManager) GetStatus(ctx context.Context, id InstanceID) (string, error) { + inspectRes, err := m.cli.ContainerInspect(ctx, string(id)) + if err != nil { + return "", err + } + + return inspectRes.State.Status, nil +} + +func (m *dockerManager) StoreContainerLogs(ctx context.Context, ID InstanceID, store func(logMessage string)) { + localLog := m.log.With().Str("cid", ctx.Value(tracer.CtxCidKey{}).(string)).Logger() + localLog.Trace().Msg("StoreContainerLogs called") + hijackedCon, err := m.cli.ContainerAttach(ctx, string(ID), container.AttachOptions{ + Stream: true, + Stdin: false, + Stdout: true, + Stderr: true, + DetachKeys: "", + Logs: true, + }) + if err != nil { + localLog.Error().Err(err).Msg("failed to get container logs") + + return + } + localLog.Trace().Msg("got container logs") + defer func() { + hijackedCon.Close() + }() + + scanner := bufio.NewScanner(hijackedCon.Reader) + localLog.Trace().Msg("starting to scan logs") + for { + if ctx.Err() != nil { + localLog.Error().Err(ctx.Err()).Msg("ctx error") + break + } + if !scanner.Scan() { + localLog.Trace().Err(scanner.Err()).Msg("scanner scan returned false") + break + } + s := scanner.Text() + + store(s) + } +} + +func (m *dockerManager) RemoveContainer(ctx context.Context, id InstanceID) error { + return m.cli.ContainerRemove(ctx, string(id), container.RemoveOptions{}) +} diff --git a/console/service/internal/xdocker/manager_utils.go b/console/service/internal/xdocker/manager_utils.go new file mode 100644 index 000000000..50e265555 --- /dev/null +++ b/console/service/internal/xdocker/manager_utils.go @@ -0,0 +1,44 @@ +package xdocker + +import ( + "context" + "io" + "postgresql-cluster-console/pkg/tracer" + "strings" + + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/errdefs" +) + +func (m *dockerManager) pullImage(ctx context.Context, dockerImage string) error { + 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") + + return err + } + } + 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") + } + }() + + buf := strings.Builder{} + _, _ = io.Copy(&buf, out) + localLog.Trace().Str("log", buf.String()).Msg("pull image") + + return nil +} diff --git a/console/service/internal/xdocker/round_tripper_log.go b/console/service/internal/xdocker/round_tripper_log.go new file mode 100644 index 000000000..8e761b2fd --- /dev/null +++ b/console/service/internal/xdocker/round_tripper_log.go @@ -0,0 +1,101 @@ +package xdocker + +import ( + "bytes" + "io" + "net/http" + "postgresql-cluster-console/pkg/tracer" + + "github.com/docker/docker/client" + "github.com/docker/go-connections/sockets" + "github.com/rs/zerolog" +) + +type roundTripperLog struct { + http.Transport + log zerolog.Logger +} + +func NewRoundTripperLog(host string, log zerolog.Logger) (http.RoundTripper, error) { + rt := &roundTripperLog{ + log: log, + } + + hostURL, err := client.ParseHostURL(host) + if err != nil { + return nil, err + } + + err = sockets.ConfigureTransport(&rt.Transport, hostURL.Scheme, hostURL.Host) + if err != nil { + return nil, err + } + + return rt, nil +} + +func (rt *roundTripperLog) RoundTrip(request *http.Request) (*http.Response, error) { + var ( + copyBody io.ReadCloser + err error + ) + localLog := rt.log.With().Str("cid", request.Context().Value(tracer.CtxCidKey{}).(string)).Logger() + if request.Body != nil { + copyBody, err = request.GetBody() + if err != nil { + localLog.Error().Err(err).Msgf("failed to GetBody") + } else { + defer func() { + err = copyBody.Close() + if err != nil { + localLog.Error().Err(err).Msg("failed to close copy of body") + } + }() + body, err := io.ReadAll(copyBody) + if err != nil { + localLog.Error().Err(err).Msg("failed to ReadAll request body") + } else { + localLog.Trace().Str("url", request.URL.Path).Str("host", request.URL.Host).Str("method", request.Method).Str("body", string(body)).Msg("request body") + } + } + } else { + localLog.Trace().Str("url", request.URL.Path).Str("host", request.URL.Host).Str("method", request.Method).Msg("request") + } + + res, err := rt.Transport.RoundTrip(request) + if err != nil { + localLog.Error().Err(err).Msg("failed to RoundTrip") + } else { + var respBody io.ReadCloser + respBody, res.Body, err = drainBody(res.Body) + if err != nil { + localLog.Error().Err(err).Msg("failed to drain body") + } else { + defer func() { + err = respBody.Close() + if err != nil { + localLog.Error().Err(err).Msg("failed to close response body") + } + }() + body, err := io.ReadAll(respBody) + if err != nil { + localLog.Error().Err(err).Msg("failed to ReadAll response body") + } else { + localLog.Trace().Str("url", request.URL.Path).Str("host", request.URL.Host).Str("body", string(body)).Msg("response body") + } + } + } + + return res, err +} + +func drainBody(b io.ReadCloser) (r1, r2 io.ReadCloser, err error) { + var buf bytes.Buffer + if _, err = buf.ReadFrom(b); err != nil { + return nil, b, err + } + if err = b.Close(); err != nil { + return nil, b, err + } + return io.NopCloser(&buf), io.NopCloser(bytes.NewReader(buf.Bytes())), nil +} diff --git a/console/service/main.go b/console/service/main.go new file mode 100644 index 000000000..4dd6bfe72 --- /dev/null +++ b/console/service/main.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + _ "embed" + "fmt" + "os" + "postgresql-cluster-console/internal/configuration" + "postgresql-cluster-console/internal/db" + "postgresql-cluster-console/internal/service" + "postgresql-cluster-console/internal/storage" + "postgresql-cluster-console/internal/watcher" + "postgresql-cluster-console/internal/xdocker" + "postgresql-cluster-console/migrations" + "postgresql-cluster-console/pkg/patroni" + "postgresql-cluster-console/pkg/tracer" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +//go:embed VERSION +var Version string + +const appName = "pg_console" + +func init() { + log.Logger = zerolog.New(os.Stdout).With(). + Timestamp(). + Str("app", appName). + Str("version", Version). + Logger() +} + +func main() { + if len(os.Args) > 1 { + configuration.PrintUsage() + + return + } + + cfg, err := configuration.ReadConfig() + if err != nil { + fmt.Print(err.Error()) + + return + } + log.Info().Interface("config", cfg).Msg("config was parsed") + + l, err := zerolog.ParseLevel(cfg.Logger.Level) + if err != nil { + log.Error().Str("log_level", cfg.Logger.Level).Msg("unknown log level") + } else { + zerolog.SetGlobalLevel(l) + log.Info().Str("log_level", cfg.Logger.Level).Msg("log level was set") + } + + dbPool, err := db.NewDbPool(cfg) + if err != nil { + log.Error().Err(err).Msg("failed to create db pool") + + return + } + + err = migrations.Migrate(dbPool, cfg.Db.MigrationDir) + if err != nil { + log.Error().Err(err).Msg("failed to make db migration") + + return + } + + str := storage.NewDbStorage(dbPool) + dockerManager, err := xdocker.NewDockerManager(cfg.Docker.Host, cfg.Docker.Image) + if err != nil { + log.Error().Err(err).Msg("failed to create docker manager") + + return + } + + ctx, cancel := context.WithCancel(context.WithValue(context.Background(), tracer.CtxCidKey{}, "")) + go func() { + log.Info().Msgf("preload docker image: %s", cfg.Docker.Image) + dockerManager.PreloadImage(ctx) + }() + defer cancel() + + logWatcher := watcher.NewLogWatcher(str, dockerManager, cfg) + logWatcher.Run() + defer logWatcher.Stop() + + logAggregator := watcher.NewLogCollector(str, dockerManager) + defer logAggregator.Stop() + + clusterWatcher := watcher.NewServerWatcher(str, patroni.NewClient(log.Logger), cfg) + clusterWatcher.Run() + defer clusterWatcher.Stop() + + s, err := service.NewService(cfg, Version, str, dockerManager, logAggregator, clusterWatcher) + if err != nil { + log.Error().Err(err).Msg("failed to create service") + + return + } + + err = s.Serve() + if err != nil { + log.Error().Err(err).Msg("service was finished with error") + } else { + log.Info().Msg("service was successfully stopped") + } +} diff --git a/console/service/middleware/authorization.go b/console/service/middleware/authorization.go new file mode 100644 index 000000000..cea51bc5a --- /dev/null +++ b/console/service/middleware/authorization.go @@ -0,0 +1,34 @@ +package middleware + +import ( + "encoding/json" + "fmt" + "net/http" + "postgresql-cluster-console/models" + "strings" +) + +func Authorization(token string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + const ( + headerName = "Authorization" + schemeName = "Bearer" + ) + tokenVal := r.Header.Get(headerName) + tokenValSplit := strings.Split(tokenVal, " ") + if len(tokenValSplit) != 2 || tokenValSplit[0] != schemeName || tokenValSplit[1] != token { + w.Header().Add("content-type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + resp, _ := json.Marshal(&models.ResponseError{ + Code: http.StatusUnauthorized, + Description: fmt.Sprintf("token [%s] invalid", tokenVal), + Title: "Invalid token", + }) + _, _ = w.Write(resp) + + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/console/service/middleware/cid.go b/console/service/middleware/cid.go new file mode 100644 index 000000000..7ca07de06 --- /dev/null +++ b/console/service/middleware/cid.go @@ -0,0 +1,30 @@ +package middleware + +import ( + "context" + "net/http" + "postgresql-cluster-console/pkg/tracer" + + "github.com/google/uuid" +) + +func SetCorrelationId(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cid := getCid(r) + if r.Header.Get(XCorrID) == "" { + r.Header.Set(XCorrID, cid) + } + + ctx := context.WithValue(r.Context(), tracer.CtxCidKey{}, cid) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func getCid(r *http.Request) string { + cid := r.Header.Get(XCorrID) + if cid != "" { + return cid + } + + return uuid.New().String() +} diff --git a/console/service/middleware/cors.go b/console/service/middleware/cors.go new file mode 100644 index 000000000..82d415ade --- /dev/null +++ b/console/service/middleware/cors.go @@ -0,0 +1,19 @@ +package middleware + +import "net/http" + +func CORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Methods", " GET, POST, OPTIONS, PATCH, DELETE, PUT") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Expose-Headers", "X-Log-Completed, X-Cluster-Id") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Origin,Accept, "+ + "X-Requested-With, Content-Type, Access-Control-Request-Method, Access-Control-Request-Headers, X-Log-Completed, X-Cluster-Id") + + if r.Method != http.MethodOptions { + next.ServeHTTP(w, r) + } + }) +} diff --git a/console/service/middleware/request_log.go b/console/service/middleware/request_log.go new file mode 100644 index 000000000..2d0d52eef --- /dev/null +++ b/console/service/middleware/request_log.go @@ -0,0 +1,166 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "io" + "net/http" + "time" +) + +const XCorrID = "X-Correlation-Id" + +const responseWriterBodyLimit = 10000 + +type responseWriter struct { + http.ResponseWriter + body []byte + statusCode int + bodyOverflow bool +} + +func GetResponseWriterCode(w http.ResponseWriter) int { + if wNew, ok := w.(*responseWriter); ok { + return wNew.statusCode + } + return 0 +} + +func (r *responseWriter) Write(b []byte) (int, error) { + if len(r.body) < responseWriterBodyLimit { + maxWriteLen := responseWriterBodyLimit - len(r.body) + if len(b) > maxWriteLen { + r.body = append(r.body, b[:maxWriteLen]...) + r.bodyOverflow = true + } else { + r.body = append(r.body, b...) + } + } + + return r.ResponseWriter.Write(b) +} + +func (r *responseWriter) WriteHeader(statusCode int) { + r.statusCode = statusCode + r.ResponseWriter.WriteHeader(statusCode) +} + +func (r *responseWriter) zerologResponse(log zerolog.Logger) { + body := r.prepareBodyForLog() + + log.Debug(). + Any("body", body). + Any("headers", r.Header()). + Int("status", r.getStatusCode()).Msg("[zerologResponse] Response was sent") +} + +func (r *responseWriter) getStatusCode() int { + if r.statusCode == 0 { + return 200 + } + + return r.statusCode +} + +func (r *responseWriter) prepareBodyForLog() any { + var body map[string]interface{} + if r.bodyOverflow { + return r.body + } + _ = json.Unmarshal(r.body, &body) + replaceFields(body, secretFields) + + return body +} + +var secretFields = map[string]string{ + "AWS_SECRET_ACCESS_KEY": "***", + "GCP_SERVICE_ACCOUNT_CONTENTS": "***", + "HCLOUD_API_TOKEN": "***", + "SSH_PRIVATE_KEY": "***", + "DO_API_TOKEN": "***", + "PASSWORD": "***", + "password": "***", + "AZURE_SECRET": "***", +} + +func RequestZeroLog(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cid := r.Header.Get(XCorrID) + clog := log.With(). + Str("cid", cid). + Str("method", r.Method). + Str("path", r.URL.String()). + Str("protocol", r.Proto). + Int64("request_length", r.ContentLength). + Logger() + + var ( + body []byte + bodyInt map[string]interface{} + bodyCopy io.ReadCloser + err error + ) + + if r.Body != nil && r.Body != http.NoBody { + bodyCopy, r.Body, err = drainBody(r.Body) + if err == nil { + body, err = io.ReadAll(bodyCopy) + if err != nil { + clog.Error().Err(err).Msg("[RequestZeroLog] read body error") + } + } else { + clog.Error().Err(err).Msg("[RequestZeroLog] drainBody failed") + } + } + + err = json.Unmarshal(body, &bodyInt) + if err != nil { + clog.Debug(). + Any("headers", r.Header). + Any("query", r.URL.Query()). + Bytes("body", body). + Msg("[RequestLog] request accepted") + } else { + replaceFields(bodyInt, secretFields) + clog.Debug(). + Any("headers", r.Header). + Any("query", r.URL.Query()). + Any("body", bodyInt). + Msg("[RequestLog] request accepted") + } + + w.Header().Set(XCorrID, cid) + + start := time.Now() + wExt := &responseWriter{ResponseWriter: w} + next.ServeHTTP(wExt, r) + duration := time.Since(start) + + wExt.zerologResponse(clog) + + clog.Debug(). + Int("status", wExt.getStatusCode()). + Dur("handle_time", duration). // request_time + Int("response_length", len(wExt.body)). + Msg("[RequestLog] request completed") + }) +} + +// drainBody reads all of b to memory and then returns two equivalent +// ReadClosers yielding the same bytes. +// +// It returns an error if the initial slurp of all bytes fails. It does not attempt +// to make the returned ReadClosers have identical error-matching behavior. +func drainBody(b io.ReadCloser) (r1, r2 io.ReadCloser, err error) { + var buf bytes.Buffer + if _, err = buf.ReadFrom(b); err != nil { + return nil, b, err + } + if err = b.Close(); err != nil { + return nil, b, err + } + return io.NopCloser(&buf), io.NopCloser(bytes.NewReader(buf.Bytes())), nil +} diff --git a/console/service/middleware/utils.go b/console/service/middleware/utils.go new file mode 100644 index 000000000..02170315e --- /dev/null +++ b/console/service/middleware/utils.go @@ -0,0 +1,33 @@ +package middleware + +import "reflect" + +func replaceFields(data map[string]interface{}, replacements map[string]string) { + for key, val := range data { + if replacement, ok := replacements[key]; ok { + data[key] = replacement + } else { + valReflect := reflect.ValueOf(val) + switch valReflect.Kind() { + case reflect.Map: + if innerMap, ok := val.(map[string]interface{}); ok { + replaceFields(innerMap, replacements) + } + case reflect.Slice: + for j := 0; j < valReflect.Len(); j++ { + elemVal := valReflect.Index(j) + if innerElemMap, ok := elemVal.Interface().(map[string]interface{}); ok { + replaceFields(innerElemMap, replacements) + } + } + case reflect.Ptr: + if !valReflect.IsNil() { + elem := valReflect.Elem().Interface() + if innerMap, ok := elem.(map[string]interface{}); ok { + replaceFields(innerMap, replacements) + } + } + } + } + } +} diff --git a/console/service/migrations/goose_logger.go b/console/service/migrations/goose_logger.go new file mode 100644 index 000000000..fac91c5c6 --- /dev/null +++ b/console/service/migrations/goose_logger.go @@ -0,0 +1,22 @@ +package migrations + +import ( + "github.com/pressly/goose/v3" + "github.com/rs/zerolog" +) + +type zeroLogAdapter struct { + log zerolog.Logger +} + +func NewZeroLogAdapter(log zerolog.Logger) goose.Logger { + return zeroLogAdapter{log: log.With().Str("module", "goouse").Logger()} +} + +func (l zeroLogAdapter) Fatalf(format string, v ...interface{}) { + l.log.Error().Msgf(format, v...) +} + +func (l zeroLogAdapter) Printf(format string, v ...interface{}) { + l.log.Info().Msgf(format, v...) +} diff --git a/console/service/migrations/migrate.go b/console/service/migrations/migrate.go new file mode 100644 index 000000000..7dc85064c --- /dev/null +++ b/console/service/migrations/migrate.go @@ -0,0 +1,18 @@ +package migrations + +import ( + "context" + + "github.com/rs/zerolog/log" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" +) + +func Migrate(dbPool *pgxpool.Pool, migrationDir string) error { + db := stdlib.OpenDBFromPool(dbPool) + goose.SetLogger(NewZeroLogAdapter(log.Logger)) + + return goose.RunContext(context.Background(), "up", db, migrationDir) +} diff --git a/console/service/pkg/patroni/client.go b/console/service/pkg/patroni/client.go new file mode 100644 index 000000000..95c92d835 --- /dev/null +++ b/console/service/pkg/patroni/client.go @@ -0,0 +1,103 @@ +package patroni + +import ( + "context" + "encoding/json" + "io" + "net/http" + "postgresql-cluster-console/pkg/tracer" + "time" + + "github.com/rs/zerolog" +) + +type IClient interface { + GetMonitoringInfo(ctx context.Context, host string) (*MonitoringInfo, error) + GetClusterInfo(ctx context.Context, host string) (*ClusterInfo, error) +} + +type pClient struct { + log zerolog.Logger + httpClient *http.Client +} + +func NewClient(log zerolog.Logger) IClient { + return pClient{ + log: log, + httpClient: &http.Client{ + Timeout: time.Second, + }, + } +} + +func (c pClient) GetMonitoringInfo(ctx context.Context, host string) (*MonitoringInfo, error) { + cid := ctx.Value(tracer.CtxCidKey{}).(string) + localLog := c.log.With().Str("cid", cid).Logger() + url := "http://" + host + ":8008/patroni" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + localLog.Trace().Str("request", "GET "+url).Msg("call request") + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { + derr := resp.Body.Close() + if derr != nil { + localLog.Error().Err(derr).Msg("failed to close body") + } + }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + localLog.Trace().Str("response", string(body)).Msg("got response") + + var monitoringInfo MonitoringInfo + err = json.Unmarshal(body, &monitoringInfo) + if err != nil { + return nil, err + } + + return &monitoringInfo, nil +} + +func (c pClient) GetClusterInfo(ctx context.Context, host string) (*ClusterInfo, error) { + cid := ctx.Value(tracer.CtxCidKey{}).(string) + localLog := c.log.With().Str("cid", cid).Logger() + url := "http://" + host + ":8008/cluster" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + localLog.Trace().Str("request", "GET "+url).Msg("call request") + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { + derr := resp.Body.Close() + if derr != nil { + localLog.Error().Err(derr).Msg("failed to close body") + } + }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + localLog.Trace().Str("response", string(body)).Msg("got response") + + var clusterInfo ClusterInfo + err = json.Unmarshal(body, &clusterInfo) + if err != nil { + return nil, err + } + + return &clusterInfo, nil +} diff --git a/console/service/pkg/patroni/models.go b/console/service/pkg/patroni/models.go new file mode 100644 index 000000000..e06fb8057 --- /dev/null +++ b/console/service/pkg/patroni/models.go @@ -0,0 +1,20 @@ +package patroni + +type MonitoringInfo struct { + State string `json:"state"` + Role string `json:"role"` + ServerVersion int `json:"server_version"` +} + +type ClusterInfo struct { + Members []struct { + Name string `json:"name"` + Role string `json:"role"` + State string `json:"state"` + Host string `json:"host"` + Timeline int64 `json:"timeline"` + Lag interface{} `json:"lag"` + Tags interface{} `json:"tags"` + PendingRestart bool `json:"pending_restart"` + } `json:"members"` +} diff --git a/console/service/pkg/tracer/cid.go b/console/service/pkg/tracer/cid.go new file mode 100644 index 000000000..d364d0c3a --- /dev/null +++ b/console/service/pkg/tracer/cid.go @@ -0,0 +1,3 @@ +package tracer + +type CtxCidKey struct{} diff --git a/console/service/restapi/configure_pg_console.go b/console/service/restapi/configure_pg_console.go new file mode 100644 index 000000000..0a2eb5af4 --- /dev/null +++ b/console/service/restapi/configure_pg_console.go @@ -0,0 +1,131 @@ +// This file is safe to edit. Once it exists it will not be overwritten + +package restapi + +import ( + "crypto/tls" + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/middleware" + + localmid "postgresql-cluster-console/middleware" + "postgresql-cluster-console/restapi/operations" + "postgresql-cluster-console/restapi/operations/cluster" + "postgresql-cluster-console/restapi/operations/dictionary" + "postgresql-cluster-console/restapi/operations/system" +) + +//go:generate swagger generate server --target ../../pg_console --name PgConsole --spec ../api/swagger.yaml --principal interface{} --exclude-main + +func configureFlags(api *operations.PgConsoleAPI) { + // api.CommandLineOptionsGroups = []swag.CommandLineOptionsGroup{ ... } +} + +func configureAPI(api *operations.PgConsoleAPI) http.Handler { + // configure the api here + api.ServeError = errors.ServeError + + // Set your custom logger if needed. Default one is log.Printf + // Expected interface func(string, ...interface{}) + // + // Example: + // api.Logger = log.Printf + + api.UseSwaggerUI() + // To continue using redoc as your UI, uncomment the following line + // api.UseRedoc() + + api.JSONConsumer = runtime.JSONConsumer() + + api.JSONProducer = runtime.JSONProducer() + + if api.ClusterGetClustersHandler == nil { + api.ClusterGetClustersHandler = cluster.GetClustersHandlerFunc(func(params cluster.GetClustersParams) middleware.Responder { + return middleware.NotImplemented("operation cluster.GetClusters has not yet been implemented") + }) + } + if api.ClusterGetClustersIDHandler == nil { + api.ClusterGetClustersIDHandler = cluster.GetClustersIDHandlerFunc(func(params cluster.GetClustersIDParams) middleware.Responder { + return middleware.NotImplemented("operation cluster.GetClustersID has not yet been implemented") + }) + } + if api.DictionaryGetDatabaseExtensionsHandler == nil { + api.DictionaryGetDatabaseExtensionsHandler = dictionary.GetDatabaseExtensionsHandlerFunc(func(params dictionary.GetDatabaseExtensionsParams) middleware.Responder { + return middleware.NotImplemented("operation dictionary.GetDatabaseExtensions has not yet been implemented") + }) + } + if api.DictionaryGetExternalDeploymentsHandler == nil { + api.DictionaryGetExternalDeploymentsHandler = dictionary.GetExternalDeploymentsHandlerFunc(func(params dictionary.GetExternalDeploymentsParams) middleware.Responder { + return middleware.NotImplemented("operation dictionary.GetExternalDeployments has not yet been implemented") + }) + } + if api.SystemGetVersionHandler == nil { + api.SystemGetVersionHandler = system.GetVersionHandlerFunc(func(params system.GetVersionParams) middleware.Responder { + return middleware.NotImplemented("operation system.GetVersion has not yet been implemented") + }) + } + if api.ClusterPostClustersHandler == nil { + api.ClusterPostClustersHandler = cluster.PostClustersHandlerFunc(func(params cluster.PostClustersParams) middleware.Responder { + return middleware.NotImplemented("operation cluster.PostClusters has not yet been implemented") + }) + } + if api.ClusterPostClustersIDReinitHandler == nil { + api.ClusterPostClustersIDReinitHandler = cluster.PostClustersIDReinitHandlerFunc(func(params cluster.PostClustersIDReinitParams) middleware.Responder { + return middleware.NotImplemented("operation cluster.PostClustersIDReinit has not yet been implemented") + }) + } + if api.ClusterPostClustersIDReloadHandler == nil { + api.ClusterPostClustersIDReloadHandler = cluster.PostClustersIDReloadHandlerFunc(func(params cluster.PostClustersIDReloadParams) middleware.Responder { + return middleware.NotImplemented("operation cluster.PostClustersIDReload has not yet been implemented") + }) + } + if api.ClusterPostClustersIDRestartHandler == nil { + api.ClusterPostClustersIDRestartHandler = cluster.PostClustersIDRestartHandlerFunc(func(params cluster.PostClustersIDRestartParams) middleware.Responder { + return middleware.NotImplemented("operation cluster.PostClustersIDRestart has not yet been implemented") + }) + } + if api.ClusterPostClustersIDStartHandler == nil { + api.ClusterPostClustersIDStartHandler = cluster.PostClustersIDStartHandlerFunc(func(params cluster.PostClustersIDStartParams) middleware.Responder { + return middleware.NotImplemented("operation cluster.PostClustersIDStart has not yet been implemented") + }) + } + if api.ClusterPostClustersIDStopHandler == nil { + api.ClusterPostClustersIDStopHandler = cluster.PostClustersIDStopHandlerFunc(func(params cluster.PostClustersIDStopParams) middleware.Responder { + return middleware.NotImplemented("operation cluster.PostClustersIDStop has not yet been implemented") + }) + } + + api.PreServerShutdown = func() {} + + api.ServerShutdown = func() {} + + return setupGlobalMiddleware(api.Serve(setupMiddlewares)) +} + +// The TLS configuration before HTTPS server starts. +func configureTLS(tlsConfig *tls.Config) { + // Make all necessary changes to the TLS configuration here. +} + +// As soon as server is initialized but not run yet, this function will be called. +// If you need to modify a config, store server instance to stop it individually later, this is the place. +// This function can be called multiple times, depending on the number of serving schemes. +// scheme value will be set accordingly: "http", "https" or "unix". +func configureServer(s *http.Server, scheme, addr string) { +} + +// The middleware configuration is for the handler executors. These do not apply to the swagger.json document. +// The middleware executes after routing but before authentication, binding and validation. +func setupMiddlewares(handler http.Handler) http.Handler { + return handler +} + +var Token string + +// The middleware configuration happens before anything, this middleware also applies to serving the swagger.json document. +// So this is a good place to plug in a panic handling middleware, logging and metrics. +func setupGlobalMiddleware(handler http.Handler) http.Handler { + return localmid.SetCorrelationId(localmid.CORS(localmid.RequestZeroLog(localmid.Authorization(Token, handler)))) +} diff --git a/console/supervisord.conf b/console/supervisord.conf new file mode 100644 index 000000000..5dfdce2bf --- /dev/null +++ b/console/supervisord.conf @@ -0,0 +1,41 @@ +[supervisord] +nodaemon=true +user=root +pidfile=/var/run/supervisord.pid +logfile=/var/log/supervisor/supervisord.log +childlogdir=/var/log/supervisor + +[unix_http_server] +file=/var/run/supervisor.sock + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl=unix:///var/run/supervisor.sock + +[program:pg-console-db] +command=/pg_start.sh +startsecs=5 +priority=100 +autostart=true +stdout_logfile=/var/log/supervisor/pg-console-db-stdout.log +stderr_logfile=/var/log/supervisor/pg-console-db-stderr.log + +[program:pg-console-api] +command=/usr/local/bin/pg-console +startsecs=5 +startretries=3 +priority=200 +autostart=true +stdout_logfile=/var/log/supervisor/pg-console-api-stdout.log +stderr_logfile=/var/log/supervisor/pg-console-api-stderr.log + +[program:pg-console-ui] +command=/bin/bash -c "/usr/share/nginx/html/env.sh && /usr/sbin/nginx -g 'daemon off;'" +startsecs=5 +startretries=3 +priority=300 +autostart=true +stdout_logfile=/var/log/supervisor/pg-console-ui-stdout.log +stderr_logfile=/var/log/supervisor/pg-console-ui-stderr.log diff --git a/console/ui/.env b/console/ui/.env new file mode 100644 index 000000000..f89b2563f --- /dev/null +++ b/console/ui/.env @@ -0,0 +1,6 @@ +VITE_API_URL=http://localhost:8080/api/v1/ +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 diff --git a/console/ui/.env.production b/console/ui/.env.production new file mode 100644 index 000000000..78376365b --- /dev/null +++ b/console/ui/.env.production @@ -0,0 +1,6 @@ +VITE_API_URL=REPLACE_ME_WITH_API_URL +VITE_AUTH_TOKEN=REPLACE_ME_WITH_AUTH_TOKEN +VITE_CLUSTERS_POLLING_INTERVAL=REPLACE_ME_WITH_CLUSTERS_POLLING_INTERVAL +VITE_CLUSTER_OVERVIEW_POLLING_INTERVAL=REPLACE_ME_WITH_CLUSTER_OVERVIEW_POLLING_INTERVAL +VITE_OPERATIONS_POLLING_INTERVAL=REPLACE_ME_WITH_OPERATIONS_POLLING_INTERVAL +VITE_OPERATION_LOGS_POLLING_INTERVAL=REPLACE_ME_WITH_OPERATION_LOGS_POLLING_INTERVAL \ No newline at end of file diff --git a/console/ui/.eslintrc.cjs b/console/ui/.eslintrc.cjs new file mode 100644 index 000000000..ca2a2f7c8 --- /dev/null +++ b/console/ui/.eslintrc.cjs @@ -0,0 +1,28 @@ +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' + }, +} diff --git a/console/ui/.gitignore b/console/ui/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/console/ui/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/console/ui/.prettierignore b/console/ui/.prettierignore new file mode 100644 index 000000000..c2ed210f5 --- /dev/null +++ b/console/ui/.prettierignore @@ -0,0 +1,6 @@ +build/ +dist/ +node_modules/ +public/ +package.json +yarn.lock diff --git a/console/ui/.prettierrc b/console/ui/.prettierrc new file mode 100644 index 000000000..e47db5689 --- /dev/null +++ b/console/ui/.prettierrc @@ -0,0 +1,12 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "bracketSameLine": true, + "arrowParens": "always", + "endOfLine": "auto", + "bracketSpacing": true +} diff --git a/console/ui/Dockerfile b/console/ui/Dockerfile new file mode 100644 index 000000000..9f9d98fb4 --- /dev/null +++ b/console/ui/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-bookworm AS builder + +WORKDIR /usr/src/pg-console + +COPY console/ui/ . + +RUN yarn install --frozen-lockfile --network-timeout 1000000 && yarn vite build + +FROM nginx:1.26-bookworm AS runtime +LABEL maintainer="Vitaliy Kukharik vitabaks@gmail.com" + +WORKDIR /usr/share/nginx/html + +COPY --from=builder /usr/src/pg-console/dist ./ +COPY console/ui/nginx/nginx.conf /etc/nginx/ +COPY console/ui/env.sh console/ui/.env console/ui/.env.production ./ + +RUN chmod +x ./env.sh + +CMD ["/bin/bash", "-c", "/usr/share/nginx/html/env.sh && nginx -g \"daemon off;\""] diff --git a/console/ui/README.md b/console/ui/README.md new file mode 100644 index 000000000..3739e8833 --- /dev/null +++ b/console/ui/README.md @@ -0,0 +1,120 @@ +# PostgreSQL Cluster Console UI + +The UI part of PostgreSQL Cluster Console. This project provides a user-friendly web interface for managing, monitoring, and configuring Postgres clusters. + +## Features + +- **Cluster management**: Create Postgres clusters for multiple cloud providers or your own machines. +- **Cluster overview**: View general information and status of Postgres cluster. +- **Operations**: View cluster operations and deployment logs. +- **Projects**: Create multiple projects with different clusters. +- **Environments**: Create multiple environments for clusters. +- **Settings**: Use proxy servers to deploy clusters (optional). +- **Secrets**: Easily manage multiple credentials, including cloud secrets, SSH keys, and passwords. + +## Installation + +To run this project locally, follow these steps: + +1. **Clone repository** + +``` +git clone https://github.com/vitabaks/postgresql_cluster.git +cd postgresql_cluster/console/ui +``` + +2. **Install dependencies** + +```yarn install``` + +3. **Start development server** + +```yarn run dev``` + +## Usage + +### Running the App in Development Mode + +1. Ensure you have installed all dependencies with ```yarn install```. +2. Start the development server with ```yarn run dev```. +3. Browser with app should open automatically. If this didn't happen open your browser and navigate + to http://localhost:5173. + +### Building for Production + +To create a production build: + +```yarn run build``` + +The optimized build will be output to the `dist` folder. You can then serve this with any static server. + +## Technology Stack + +**UI:** + +- React +- Redux Toolkit (RTK Query for data fetching) +- React Router v6 +- Vite (development and build tool) +- Material UI (UI kit) +- Material React Table V2 +- React-toastify + +**Deployment:** + +- Docker (included Dockerfile for quick deployment) +- Nginx (project configuration included) + +## Configuration + +There are several env variables that configure UI: + +| KEY | DEFAULT | DESCRIPTION | +|----------------------------------------------|------------------------------|-------------------------------------------------------------| +| PG_CONSOLE_API_URL | http://localhost:8080/api/v1 | Default API URL where frontend will be sending requests to. | +| PG_CONSOLE_AUTHORIZATION_TOKEN | auth_token | Reference auth token that will be used for login. | +| PG_CONSOLE_CLUSTERS_POLLING_INTERVAL | 60000 | Clusters table refresh interval in milliseconds. | +| PG_CONSOLE_CLUSTER_OVERVIEW_POLLING_INTERVAL | 60000 | Cluster overview refresh interval in milliseconds. | +| PG_CONSOLE_OPERATIONS_POLLING_INTERVAL | 60000 | Operations table refresh interval in milliseconds. | +| PG_CONSOLE_OPERATION_LOGS_POLLING_INTERVAL | 10000 | Operation logs refresh interval in milliseconds. | + +## Architecture + +UI uses [Feature-Sliced Design](https://feature-sliced.design/) v2 approach to implement architecture. +This design pattern divides the application into distinct layers and slices, each with a specific role and +responsibility, to promote isolation, reusability, and easy maintenance. + +### Feature-Sliced Design Overview + +#### Layers + +1. **App Layer** + + - Description: This is the top-level layer, responsible for initializing the application, setting up providers (like + routers, states, etc.), and global styles. + - Contents: + - App: Main application component that integrates all providers and initializes the app. + - providers: Context providers such as Redux Provider, Router, Theme, etc. + - styles: Global styles and theming. + +2. **Pages Layer** + + - Description: Represents the application screens or pages. Each page can consist of multiple features and/or entities. + - Contents: Page components like AddCluster, Login, 404, etc. + +3. **Features Layer** + + - Description: This layer contains interactive components such as buttons, modals, etc. + - Contents: Feature components like AddSecret, LogoutButton, OperationsTableRowActions, etc. + +4. **Entities Layer** + + - Description: Contains core business entities of the application. Additionally, reusable form parts are also made + entities. + - Contents: Entities like SidebarItem, SecretFormBlock, etc. + +5. **Shared Layer** + + - Description: This is the foundational layer. It includes utilities, shared components, constants, and other reusable + elements that can be used across features, entities, or pages. + - Contents: Common components (CopyIcon, DefaultTable, Spinner), constants and types, utility functions. diff --git a/console/ui/env.sh b/console/ui/env.sh new file mode 100644 index 000000000..494f457d3 --- /dev/null +++ b/console/ui/env.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +SEARCH_DIR="/usr/share/nginx/html/" + +# Set default values for environment variables if they are not set +export VITE_API_URL=${VITE_API_URL:-${PG_CONSOLE_API_URL:-"http://localhost:8080/api/v1"}} +export VITE_AUTH_TOKEN=${VITE_AUTH_TOKEN:-${PG_CONSOLE_AUTHORIZATION_TOKEN:-"auth_token"}} +export VITE_CLUSTERS_POLLING_INTERVAL=${PG_CONSOLE_CLUSTERS_POLLING_INTERVAL:-"60000"} +export VITE_CLUSTER_OVERVIEW_POLLING_INTERVAL=${PG_CONSOLE_CLUSTER_OVERVIEW_POLLING_INTERVAL:-"60000"} +export VITE_OPERATIONS_POLLING_INTERVAL=${PG_CONSOLE_OPERATIONS_POLLING_INTERVAL:-"60000"} +export VITE_OPERATION_LOGS_POLLING_INTERVAL=${PG_CONSOLE_OPERATION_LOGS_POLLING_INTERVAL:-"10000"} + +# Find all .js files in the specified directory and replace placeholders with the environment variable values +find "${SEARCH_DIR}" -type f -name '*.js' -exec sed -i -e " + s|REPLACE_ME_WITH_API_URL|${VITE_API_URL}|g; + s|REPLACE_ME_WITH_AUTH_TOKEN|${VITE_AUTH_TOKEN}|g; + s|REPLACE_ME_WITH_CLUSTERS_POLLING_INTERVAL|${VITE_CLUSTERS_POLLING_INTERVAL}|g; + s|REPLACE_ME_WITH_CLUSTER_OVERVIEW_POLLING_INTERVAL|${VITE_CLUSTER_OVERVIEW_POLLING_INTERVAL}|g; + s|REPLACE_ME_WITH_OPERATIONS_POLLING_INTERVAL|${VITE_OPERATIONS_POLLING_INTERVAL}|g; + s|REPLACE_ME_WITH_OPERATION_LOGS_POLLING_INTERVAL|${VITE_OPERATION_LOGS_POLLING_INTERVAL}|g; +" {} \; diff --git a/console/ui/index.html b/console/ui/index.html new file mode 100644 index 000000000..c53d9a151 --- /dev/null +++ b/console/ui/index.html @@ -0,0 +1,13 @@ + + + + + + + PostgreSQL Cluster Console + + +
+ + + diff --git a/console/ui/nginx/nginx.conf b/console/ui/nginx/nginx.conf new file mode 100644 index 000000000..7f2a9fb30 --- /dev/null +++ b/console/ui/nginx/nginx.conf @@ -0,0 +1,48 @@ +worker_processes auto; + +events { + worker_connections 8000; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + server { + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PATCH, DELETE, PUT' always; + add_header 'Access-Control-Allow-Headers' 'Authorization, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Origin,Accept, X-Requested-With, Content-Type, Access-Control-Request-Method, Access-Control-Request-Headers,X-Auth-Token' always; + + listen 80; + access_log /var/log/nginx/access.log; + + root /usr/share/nginx/html; + index index.html index.htm; + + location / { + try_files $uri $uri/ /index.html; + sendfile off; + add_header Last-Modified $date_gmt; + add_header Cache-Control 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'; + if_modified_since off; + expires off; + etag off; + proxy_no_cache 1; + proxy_cache_bypass 1; + } + + location ~* \.(?:css|js)$ { + try_files $uri =404; + expires 1y; + access_log off; + add_header Cache-Control "public"; + } + + location ~ ^.+\..+$ { + try_files $uri =404; + } + } +} + diff --git a/console/ui/package.json b/console/ui/package.json new file mode 100644 index 000000000..b42d94f51 --- /dev/null +++ b/console/ui/package.json @@ -0,0 +1,77 @@ +{ + "name": "postgresql-cluster-console-ui", + "private": true, + "version": "2.0.0", + "type": "module", + "scripts": { + "dev": "vite --open", + "build": "vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "tsc && vite build && vite preview", + "serve": "vite build && serve ./dist", + "apiGen": "npx @rtk-query/codegen-openapi src/shared/api/apiConfig.ts", + "vitest": "vitest run", + "vitest:watch": "vitest" + }, + "dependencies": { + "@emotion/react": "^11.11.4", + "@emotion/styled": "^11.11.5", + "@fontsource/roboto": "^5.0.13", + "@hookform/resolvers": "^3.4.2", + "@monaco-editor/react": "^4.6.0", + "@mui/icons-material": "^5.15.17", + "@mui/lab": "^5.0.0-alpha.170", + "@mui/material": "^5.15.17", + "@mui/x-data-grid": "^7.4.0", + "@mui/x-date-pickers": "^7.5.0", + "@reduxjs/toolkit": "^2.2.6", + "date-fns": "^3.6.0", + "i18next": "^23.11.3", + "i18next-browser-languagedetector": "^7.2.1", + "i18next-fs-backend": "^2.3.1", + "i18next-http-backend": "^2.5.1", + "ip-regex": "^5.0.0", + "material-react-table": "^2.13.0", + "normalize.css": "^8.0.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-error-boundary": "^4.0.13", + "react-hook-form": "^7.51.5", + "react-i18next": "^14.1.1", + "react-lazylog": "^4.5.3", + "react-redux": "^9.1.2", + "react-router-dom": "^6.23.0", + "react-toastify": "^10.0.5", + "yup": "^1.4.0" + }, + "devDependencies": { + "@faker-js/faker": "^8.4.1", + "@rtk-query/codegen-openapi": "^1.2.0", + "@testing-library/dom": "^10.1.0", + "@testing-library/jest-dom": "^6.4.5", + "@testing-library/react": "^15.0.6", + "@testing-library/user-event": "^14.5.2", + "@types/node": "^20.12.10", + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@types/react-lazylog": "^4.5.4", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "@typescript-eslint/parser": "^7.2.0", + "@vitejs/plugin-react": "^4.2.1", + "@vitejs/plugin-react-swc": "^3.6.0", + "autoprefixer": "^10.4.19", + "esbuild-plugin-react-virtualized": "^1.0.4", + "esbuild-runner": "^2.2.2", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.34.1", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.6", + "jsdom": "^24.0.0", + "prettier": "^3.2.5", + "sass": "^1.76.0", + "typescript": "^5.2.2", + "vite": "^5.2.0", + "vite-plugin-svgr": "^4.2.0", + "vitest": "^1.6.0" + } +} diff --git a/console/ui/src/app/App.tsx b/console/ui/src/app/App.tsx new file mode 100644 index 000000000..3683722fb --- /dev/null +++ b/console/ui/src/app/App.tsx @@ -0,0 +1,24 @@ +import { FC } from 'react'; +import { ThemeProvider } from '@mui/material'; +import theme from '@shared/theme/theme.ts'; +import Router from '@app/router/Router.tsx'; +import { Provider } from 'react-redux'; +import { ToastContainer } from 'react-toastify'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFnsV3'; +import { store } from '@app/redux/store/store.ts'; + +const App: FC = () => { + return ( + + + + + + + + + ); +}; + +export default App; diff --git a/console/ui/src/app/layout/index.ts b/console/ui/src/app/layout/index.ts new file mode 100644 index 000000000..7c9056883 --- /dev/null +++ b/console/ui/src/app/layout/index.ts @@ -0,0 +1,3 @@ +import Layout from './ui'; + +export default Layout; diff --git a/console/ui/src/app/layout/ui/index.tsx b/console/ui/src/app/layout/ui/index.tsx new file mode 100644 index 000000000..e3f98fe5f --- /dev/null +++ b/console/ui/src/app/layout/ui/index.tsx @@ -0,0 +1,16 @@ +import { FC } from 'react'; +import Sidebar from '@widgets/sidebar'; +import Header from '@widgets/header'; +import Main from '@widgets/main'; + +const Layout: FC = () => { + return ( +
+
+ +
+
+ ); +}; + +export default Layout; diff --git a/console/ui/src/app/main.tsx b/console/ui/src/app/main.tsx new file mode 100644 index 000000000..530783d4a --- /dev/null +++ b/console/ui/src/app/main.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.tsx'; +import 'normalize.css/normalize.css'; +import '@shared/i18n/i18n.ts'; +import '@fontsource/roboto/300.css'; +import '@fontsource/roboto/400.css'; +import '@fontsource/roboto/500.css'; +import '@fontsource/roboto/700.css'; +import 'react-toastify/dist/ReactToastify.min.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/console/ui/src/app/redux/slices/projectSlice/projectSelectors.ts b/console/ui/src/app/redux/slices/projectSlice/projectSelectors.ts new file mode 100644 index 000000000..0db80f572 --- /dev/null +++ b/console/ui/src/app/redux/slices/projectSlice/projectSelectors.ts @@ -0,0 +1,3 @@ +import { RootState } from '@app/redux/store/store.ts'; + +export const selectCurrentProject = (state: RootState) => state.project.currentProject; diff --git a/console/ui/src/app/redux/slices/projectSlice/projectSlice.ts b/console/ui/src/app/redux/slices/projectSlice/projectSlice.ts new file mode 100644 index 000000000..64ac1dfd9 --- /dev/null +++ b/console/ui/src/app/redux/slices/projectSlice/projectSlice.ts @@ -0,0 +1,24 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +interface ProjectSliceState { + currentProject: string | null; +} + +const initialState: ProjectSliceState = { + currentProject: localStorage.getItem('currentProject') ?? '', +}; + +export const projectSlice = createSlice({ + name: 'project', + initialState, + reducers: { + setProject: (state: ProjectSliceState, action: PayloadAction) => { + state.currentProject = action.payload; + localStorage.setItem('currentProject', action.payload); + }, + }, +}); + +export const { setProject } = projectSlice.actions; + +export default projectSlice.reducer; diff --git a/console/ui/src/app/redux/store/hooks.ts b/console/ui/src/app/redux/store/hooks.ts new file mode 100644 index 000000000..5cdf32436 --- /dev/null +++ b/console/ui/src/app/redux/store/hooks.ts @@ -0,0 +1,6 @@ +import { useDispatch, useSelector } from 'react-redux'; +import type { AppDispatch, RootState } from './store'; + +// Use throughout your app instead of plain `useDispatch` and `useSelector` +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); diff --git a/console/ui/src/app/redux/store/store.ts b/console/ui/src/app/redux/store/store.ts new file mode 100644 index 000000000..b80a2787b --- /dev/null +++ b/console/ui/src/app/redux/store/store.ts @@ -0,0 +1,71 @@ +import { Action, configureStore, isRejectedWithValue, Middleware, ThunkAction } from '@reduxjs/toolkit'; +import { operationsApi } from '@shared/api/api/operations'; +import { clustersApi } from '@shared/api/api/clusters.ts'; +import { environmentsApi } from '@shared/api/api/environments.ts'; +import { projectsApi } from '@shared/api/api/projects.ts'; +import { secretsApi } from '@shared/api/api/secrets.ts'; +import { settingsApi } from '@shared/api/api/settings.ts'; +import { otherApi } from '@shared/api/api/other.ts'; +import { projectSlice } from '@app/redux/slices/projectSlice/projectSlice.ts'; +import { baseApi } from '@shared/api/baseApi.ts'; +import { toast } from 'react-toastify'; +import { setupListeners } from '@reduxjs/toolkit/query'; + +export const rtkQueryErrorLogger: Middleware = () => (next) => (action) => { + if (isRejectedWithValue(action)) { + toast.error(action.payload?.data?.message || action.payload.data?.title); + console.error(action.payload?.data); + } + return next(action); +}; + +// `combineSlices` automatically combines the reducers using +// their `reducerPath`s, therefore we no longer need to call `combineReducers`. +const rootReducer = { + [baseApi.reducerPath]: baseApi.reducer, + [clustersApi.reducerPath]: clustersApi.reducer, + [environmentsApi.reducerPath]: environmentsApi.reducer, + [operationsApi.reducerPath]: operationsApi.reducer, + [projectsApi.reducerPath]: projectsApi.reducer, + [secretsApi.reducerPath]: secretsApi.reducer, + [settingsApi.reducerPath]: settingsApi.reducer, + [otherApi.reducerPath]: otherApi.reducer, + project: projectSlice.reducer, +}; + +// Infer the `RootState` type from the root reducer +export type RootState = ReturnType; + +export const makeStore = (preloadedState?: Partial) => { + const store = configureStore({ + reducer: rootReducer, + // Adding the api middleware enables caching, invalidation, polling, + // and other useful features of `rtk-query`. + middleware: (getDefaultMiddleware) => { + return getDefaultMiddleware().concat( + baseApi.middleware, + clustersApi.middleware, + environmentsApi.middleware, + operationsApi.middleware, + projectsApi.middleware, + secretsApi.middleware, + settingsApi.middleware, + otherApi.middleware, + rtkQueryErrorLogger, + ); + }, + preloadedState, + }); + // configure listeners using the provided defaults + // optional, but required for `refetchOnFocus`/`refetchOnReconnect` behaviors + setupListeners(store.dispatch); + return store; +}; + +export const store = makeStore(); + +// Infer the type of `store` +export type AppStore = typeof store; +// Infer the `AppDispatch` type from the store itself +export type AppDispatch = AppStore['dispatch']; +export type AppThunk = ThunkAction; diff --git a/console/ui/src/app/router/PrivateRouterWrapper.tsx b/console/ui/src/app/router/PrivateRouterWrapper.tsx new file mode 100644 index 000000000..fddeee181 --- /dev/null +++ b/console/ui/src/app/router/PrivateRouterWrapper.tsx @@ -0,0 +1,24 @@ +import { Navigate, Outlet, useLocation } from 'react-router-dom'; +import RouterPaths from '@app/router/routerPathsConfig'; +import { FC, useEffect } from 'react'; +import { toast } from 'react-toastify'; +import { useTranslation } from 'react-i18next'; +import { AUTH_TOKEN } from '@shared/config/constants.ts'; + +const PrivateRouteWrapper: FC = () => { + const { t } = useTranslation('toasts'); + const location = useLocation(); + + useEffect(() => { + const token = localStorage.getItem('token'); + if (token && token !== AUTH_TOKEN) toast.error(t('invalidToken')); + }, [localStorage.getItem('token')]); + + return localStorage.getItem('token') === AUTH_TOKEN ? ( + + ) : ( + + ); +}; + +export default PrivateRouteWrapper; diff --git a/console/ui/src/app/router/Router.tsx b/console/ui/src/app/router/Router.tsx new file mode 100644 index 000000000..4a78e298a --- /dev/null +++ b/console/ui/src/app/router/Router.tsx @@ -0,0 +1,49 @@ +import { FC, lazy, Suspense } from 'react'; +import { + createBrowserRouter, + createRoutesFromElements, + Navigate, + Outlet, + Route, + RouterProvider, +} from 'react-router-dom'; +import Layout from '../layout'; +import ClustersRoutes from '@app/router/routerConfig/ClustersRoutes.tsx'; +import OperationsRoutes from '@app/router/routerConfig/OperationsRoutes.tsx'; +import SettingsRoutes from '@app/router/routerConfig/SettingsRoutes.tsx'; +import RouterPaths from '@app/router/routerPathsConfig'; +import PrivateRouteWrapper from '@app/router/PrivateRouterWrapper.tsx'; +import Spinner from '@shared/ui/spinner'; + +const Login = lazy(() => import('@pages/login')); +const Page404 = lazy(() => import('@pages/404')); + +const Router: FC = () => { + const routes = createRoutesFromElements( + }> + + + }> + } /> + }> + }> + {ClustersRoutes()} + {OperationsRoutes()} + {SettingsRoutes()} + + + } /> + } /> + {/* anything that starts with "/" i.e. "/any-page" */} + , + ); + + const browserRouter = createBrowserRouter(routes); + + return ; +}; + +export default Router; diff --git a/console/ui/src/app/router/routerConfig/ClustersRoutes.tsx b/console/ui/src/app/router/routerConfig/ClustersRoutes.tsx new file mode 100644 index 000000000..29e0f62c4 --- /dev/null +++ b/console/ui/src/app/router/routerConfig/ClustersRoutes.tsx @@ -0,0 +1,37 @@ +import { lazy } from 'react'; +import { Navigate, Route } from 'react-router-dom'; +import RouterPaths from '@app/router/routerPathsConfig'; + +const Clusters = lazy(() => import('@pages/clusters')); +const AddCluster = lazy(() => import('@pages/add-cluster')); +const OverviewCluster = lazy(() => import('@pages/overview-cluster')); + +const ClustersRoutes = () => ( + + {/*redirects to "clusters" when opening homepage*/} + } /> + + } /> + } + /> + } + /> + + +); + +export default ClustersRoutes; diff --git a/console/ui/src/app/router/routerConfig/OperationsRoutes.tsx b/console/ui/src/app/router/routerConfig/OperationsRoutes.tsx new file mode 100644 index 000000000..fda368640 --- /dev/null +++ b/console/ui/src/app/router/routerConfig/OperationsRoutes.tsx @@ -0,0 +1,29 @@ +import { lazy } from 'react'; +import { Route } from 'react-router-dom'; +import RouterPaths from '@app/router/routerPathsConfig'; +import OperationLog from '@pages/operation-log'; + +const Operations = lazy(() => import('@pages/operations')); + +const OperationsRoutes = () => ( + + + } /> + `${data.operationId}`, + }, + }} + element={} + /> + + +); + +export default OperationsRoutes; diff --git a/console/ui/src/app/router/routerConfig/SettingsRoutes.tsx b/console/ui/src/app/router/routerConfig/SettingsRoutes.tsx new file mode 100644 index 000000000..3b013d146 --- /dev/null +++ b/console/ui/src/app/router/routerConfig/SettingsRoutes.tsx @@ -0,0 +1,28 @@ +import { lazy } from 'react'; +import { Navigate, Route } from 'react-router-dom'; +import RouterPaths from '@app/router/routerPathsConfig'; + +const Settings = lazy(() => import('@pages/settings')); +const SettingsForm = lazy(() => import('@widgets/settings-form')); +const SecretsTable = lazy(() => import('@widgets/secrets-table/ui')); +const ProjectsTable = lazy(() => import('@widgets/projects-table')); +const EnvironmentsTable = lazy(() => import('@widgets/environments-table')); + +const SettingsRoutes = () => ( + + }> + }> + } /> + } /> + } /> + } /> + + +); + +export default SettingsRoutes; diff --git a/console/ui/src/app/router/routerPathsConfig/index.ts b/console/ui/src/app/router/routerPathsConfig/index.ts new file mode 100644 index 000000000..3563dc35a --- /dev/null +++ b/console/ui/src/app/router/routerPathsConfig/index.ts @@ -0,0 +1,20 @@ +import routerClustersPathsConfig from '@app/router/routerPathsConfig/routerClustersPathsConfig.ts'; +import routerOperationsPathsConfig from '@app/router/routerPathsConfig/routerOperationsPathsConfig.ts'; +import routerSettingsPathsConfig from '@app/router/routerPathsConfig/routerSettingsPathsConfig.ts'; + +/* + Combines route paths into one config + */ +const RouterPaths = { + login: { + absolutePath: 'login', + }, + notFound: { + absolutePath: 'notFound', + }, + clusters: routerClustersPathsConfig, + operations: routerOperationsPathsConfig, + settings: routerSettingsPathsConfig, +} as const; + +export default RouterPaths; diff --git a/console/ui/src/app/router/routerPathsConfig/routerClustersPathsConfig.ts b/console/ui/src/app/router/routerPathsConfig/routerClustersPathsConfig.ts new file mode 100644 index 000000000..a75dc7858 --- /dev/null +++ b/console/ui/src/app/router/routerPathsConfig/routerClustersPathsConfig.ts @@ -0,0 +1,13 @@ +const routerClustersPathsConfig = { + absolutePath: '/clusters', + add: { + absolutePath: '/clusters/add', + relativePath: 'add', + }, + overview: { + absolutePath: '/clusters/:clusterId/overview', + relativePath: ':clusterId/overview', + }, +}; + +export default routerClustersPathsConfig; diff --git a/console/ui/src/app/router/routerPathsConfig/routerOperationsPathsConfig.ts b/console/ui/src/app/router/routerPathsConfig/routerOperationsPathsConfig.ts new file mode 100644 index 000000000..f8cdb3398 --- /dev/null +++ b/console/ui/src/app/router/routerPathsConfig/routerOperationsPathsConfig.ts @@ -0,0 +1,9 @@ +const routerOperationsPathsConfig = { + absolutePath: '/operations', + log: { + absolutePath: '/operations/:operationId/log', + relativePath: ':operationId/log', + }, +}; + +export default routerOperationsPathsConfig; diff --git a/console/ui/src/app/router/routerPathsConfig/routerSettingsPathsConfig.ts b/console/ui/src/app/router/routerPathsConfig/routerSettingsPathsConfig.ts new file mode 100644 index 000000000..7df9c59a0 --- /dev/null +++ b/console/ui/src/app/router/routerPathsConfig/routerSettingsPathsConfig.ts @@ -0,0 +1,21 @@ +const routerSettingsPathsConfig = { + absolutePath: '/settings', + general: { + absolutePath: '/settings/general', + relativePath: 'general', + }, + secrets: { + absolutePath: '/settings/secrets', + relativePath: 'secrets', + }, + projects: { + absolutePath: '/settings/projects', + relativePath: 'projects', + }, + environments: { + absolutePath: '/settings/environments', + relativePath: 'environments', + }, +}; + +export default routerSettingsPathsConfig; diff --git a/console/ui/src/app/vite-env.d.ts b/console/ui/src/app/vite-env.d.ts new file mode 100644 index 000000000..b1f45c786 --- /dev/null +++ b/console/ui/src/app/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/console/ui/src/entities/authentification-method-form-block/index.ts b/console/ui/src/entities/authentification-method-form-block/index.ts new file mode 100644 index 000000000..7aa00aa2c --- /dev/null +++ b/console/ui/src/entities/authentification-method-form-block/index.ts @@ -0,0 +1,3 @@ +import AuthenticationMethodFormBlock from '@entities/authentification-method-form-block/ui'; + +export default AuthenticationMethodFormBlock; diff --git a/console/ui/src/entities/authentification-method-form-block/model/constants.ts b/console/ui/src/entities/authentification-method-form-block/model/constants.ts new file mode 100644 index 000000000..1a05b07e0 --- /dev/null +++ b/console/ui/src/entities/authentification-method-form-block/model/constants.ts @@ -0,0 +1,16 @@ +import { TFunction } from 'i18next'; +import { AUTHENTICATION_METHODS } from '@shared/model/constants.ts'; + +export const authenticationMethods = (t: TFunction) => + Object.freeze([ + { + id: AUTHENTICATION_METHODS.SSH, + name: t('sshKey', { ns: 'clusters' }), + description: t('sshKeyAuthDescription', { ns: 'clusters' }), + }, + { + id: AUTHENTICATION_METHODS.PASSWORD, + name: t('password', { ns: 'shared' }), + description: t('passwordAuthDescription', { ns: 'clusters' }), + }, + ]); 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 new file mode 100644 index 000000000..2fb7f2c21 --- /dev/null +++ b/console/ui/src/entities/authentification-method-form-block/ui/AuthenticationFormPart.tsx @@ -0,0 +1,45 @@ +import React, { 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 { 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'; +import { useTranslation } from 'react-i18next'; + +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); + + return ( + <> + ( + + )} + /> + {watchAuthenticationMethod === AUTHENTICATION_METHODS.SSH ? : } + + ); +}; + +export default AuthenticationFormPart; diff --git a/console/ui/src/entities/authentification-method-form-block/ui/PasswordMethodFormPart.tsx b/console/ui/src/entities/authentification-method-form-block/ui/PasswordMethodFormPart.tsx new file mode 100644 index 000000000..fb3a3746c --- /dev/null +++ b/console/ui/src/entities/authentification-method-form-block/ui/PasswordMethodFormPart.tsx @@ -0,0 +1,37 @@ +import React, { FC } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { TextField } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; + +const PasswordMethodFormPart: FC = () => { + const { t } = useTranslation('shared'); + + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + <> + ( + + )} + /> + + ); +}; + +export default PasswordMethodFormPart; diff --git a/console/ui/src/entities/authentification-method-form-block/ui/SshMethodFormPart.tsx b/console/ui/src/entities/authentification-method-form-block/ui/SshMethodFormPart.tsx new file mode 100644 index 000000000..57aaaae53 --- /dev/null +++ b/console/ui/src/entities/authentification-method-form-block/ui/SshMethodFormPart.tsx @@ -0,0 +1,40 @@ +import React, { FC } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { TextField } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; + +const SshMethodFormPart: FC = () => { + const { t } = useTranslation(['clusters', 'shared', 'settings']); + + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + <> + ( + + )} + /> + + ); +}; + +export default SshMethodFormPart; 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 new file mode 100644 index 000000000..6c0a27fde --- /dev/null +++ b/console/ui/src/entities/authentification-method-form-block/ui/index.tsx @@ -0,0 +1,187 @@ +import React, { useEffect } from 'react'; +import { Box, Checkbox, FormControlLabel, MenuItem, Radio, Stack, TextField, Typography } 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 { 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'; +import { selectCurrentProject } from '@app/redux/slices/projectSlice/projectSelectors.ts'; +import AuthenticationFormPart from '@entities/authentification-method-form-block/ui/AuthenticationFormPart.tsx'; +import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; +import { AUTHENTICATION_METHODS } from '@shared/model/constants.ts'; + +const AuthenticationMethodFormBlock: React.FC = () => { + const { t } = useTranslation(['clusters', 'shared', 'settings']); + + const { + control, + watch, + resetField, + setValue, + formState: { errors }, + } = useFormContext(); + + 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 secrets = useGetSecretsQuery({ type: watchAuthenticationMethod, projectId: currentProject }); + + useEffect(() => { + resetField(CLUSTER_FORM_FIELD_NAMES.SECRET_ID); + }, [watchIsUseDefinedSecret, watchAuthenticationMethod]); + + useEffect(() => { + setValue(CLUSTER_FORM_FIELD_NAMES.IS_USE_DEFINED_SECRET, !!secrets.data?.data?.length); + }, [secrets.data?.data?.length]); + + return ( + + + {t('authenticationMethod', { ns: 'clusters' })} + + + + ( + <> + {authenticationMethods(t).map((method) => ( + onChange(method.id)}> + + + {method.name} + {method.description} + + + ))} + + )} + /> + + {secrets.data?.data?.length ? ( + <> + ( + + {[t('yes', { ns: 'shared' }), t('no', { ns: 'shared' })].map((option) => ( + + {option} + + ))} + + )} + /> + {watchIsUseDefinedSecret ? ( + <> + {watchAuthenticationMethod === AUTHENTICATION_METHODS.SSH ? ( + ( + + )} + /> + ) : null} + ( + + {secrets.data.data.map((secret) => ( + + {secret?.name} + + ))} + + )} + /> + + ) : ( + + )} + + ) : ( + + )} + {(secrets.data?.data?.length && !watchIsUseDefinedSecret) || !secrets.data?.data?.length ? ( + <> + {watchIsSaveToConsole ? ( + ( + + )} + /> + ) : null} + ( + } + checked={value as boolean} + onChange={onChange} + label={t('saveToConsole')} + /> + )} + /> + + ) : null} + + + ); +}; + +export default AuthenticationMethodFormBlock; diff --git a/console/ui/src/entities/breadcumb-item/index.ts b/console/ui/src/entities/breadcumb-item/index.ts new file mode 100644 index 000000000..5e908adaa --- /dev/null +++ b/console/ui/src/entities/breadcumb-item/index.ts @@ -0,0 +1,3 @@ +import BreadcrumbsItem from '@entities/breadcumb-item/ui'; + +export default BreadcrumbsItem; diff --git a/console/ui/src/entities/breadcumb-item/model/types.ts b/console/ui/src/entities/breadcumb-item/model/types.ts new file mode 100644 index 000000000..1d1de41e9 --- /dev/null +++ b/console/ui/src/entities/breadcumb-item/model/types.ts @@ -0,0 +1,4 @@ +export interface BreadcrumbsItemProps { + label: string; + path: string; +} diff --git a/console/ui/src/entities/breadcumb-item/ui/index.tsx b/console/ui/src/entities/breadcumb-item/ui/index.tsx new file mode 100644 index 000000000..a164727fa --- /dev/null +++ b/console/ui/src/entities/breadcumb-item/ui/index.tsx @@ -0,0 +1,11 @@ +import { FC } from 'react'; +import { BreadcrumbsItemProps } from '@entities/breadcumb-item/model/types.ts'; +import { Link } from 'react-router-dom'; + +const BreadcrumbsItem: FC = ({ label, path }) => ( + + {label} + +); + +export default BreadcrumbsItem; diff --git a/console/ui/src/entities/cluster-cloud-provider-block/index.ts b/console/ui/src/entities/cluster-cloud-provider-block/index.ts new file mode 100644 index 000000000..cd7e326e9 --- /dev/null +++ b/console/ui/src/entities/cluster-cloud-provider-block/index.ts @@ -0,0 +1,3 @@ +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 new file mode 100644 index 000000000..e4466d11e --- /dev/null +++ b/console/ui/src/entities/cluster-cloud-provider-block/model/types.ts @@ -0,0 +1,6 @@ +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 new file mode 100644 index 000000000..6c5797e65 --- /dev/null +++ b/console/ui/src/entities/cluster-cloud-provider-block/ui/index.tsx @@ -0,0 +1,23 @@ +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 new file mode 100644 index 000000000..8670af328 --- /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 new file mode 100644 index 000000000..54fb36a6c --- /dev/null +++ b/console/ui/src/entities/cluster-description-block/ui/index.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Controller, useFormContext } from 'react-hook-form'; +import { Box, TextField, Typography } from '@mui/material'; +import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; + +const ClusterDescriptionBlock: React.FC = () => { + const { t } = useTranslation('clusters'); + const { control } = useFormContext(); + + return ( + + + {t('description')} + + ( + + )} + /> + + ); +}; + +export default ClusterDescriptionBlock; diff --git a/console/ui/src/entities/cluster-form-cloud-region-block/assets/aws.svg b/console/ui/src/entities/cluster-form-cloud-region-block/assets/aws.svg new file mode 100644 index 000000000..b3049e1b1 --- /dev/null +++ b/console/ui/src/entities/cluster-form-cloud-region-block/assets/aws.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/console/ui/src/entities/cluster-form-cloud-region-block/assets/azure.svg b/console/ui/src/entities/cluster-form-cloud-region-block/assets/azure.svg new file mode 100644 index 000000000..3948dff06 --- /dev/null +++ b/console/ui/src/entities/cluster-form-cloud-region-block/assets/azure.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/console/ui/src/entities/cluster-form-cloud-region-block/assets/digitalocean.svg b/console/ui/src/entities/cluster-form-cloud-region-block/assets/digitalocean.svg new file mode 100644 index 000000000..b27205b5b --- /dev/null +++ b/console/ui/src/entities/cluster-form-cloud-region-block/assets/digitalocean.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/console/ui/src/entities/cluster-form-cloud-region-block/assets/gcp.svg b/console/ui/src/entities/cluster-form-cloud-region-block/assets/gcp.svg new file mode 100644 index 000000000..973d10529 --- /dev/null +++ b/console/ui/src/entities/cluster-form-cloud-region-block/assets/gcp.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/console/ui/src/entities/cluster-form-cloud-region-block/assets/hetzner.svg b/console/ui/src/entities/cluster-form-cloud-region-block/assets/hetzner.svg new file mode 100644 index 000000000..328106191 --- /dev/null +++ b/console/ui/src/entities/cluster-form-cloud-region-block/assets/hetzner.svg @@ -0,0 +1,9 @@ + + + + + + + + + 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 new file mode 100644 index 000000000..eab9a9bd2 --- /dev/null +++ b/console/ui/src/entities/cluster-form-cloud-region-block/index.ts @@ -0,0 +1,3 @@ +import CloudFormRegionBlock from '@entities/cluster-form-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-form-cloud-region-block/lib/hooks.tsx new file mode 100644 index 000000000..f179eb74d --- /dev/null +++ b/console/ui/src/entities/cluster-form-cloud-region-block/lib/hooks.tsx @@ -0,0 +1,15 @@ +import AWSIcon from '../assets/aws.svg'; +import GCPIcon from '../assets/gcp.svg'; +import AzureIcon from '../assets/azure.svg'; +import DigitalOceanIcon from '../assets/digitalocean.svg'; +import HetznerIcon from '../assets/hetzner.svg'; +import { PROVIDERS } from '@shared/config/constants.ts'; + +export const useNameIconProvidersMap = () => ({ + // TODO: refactor into moving from hooks to constant + [PROVIDERS.AWS]: AWSIcon, + [PROVIDERS.GCP]: GCPIcon, + [PROVIDERS.AZURE]: AzureIcon, + [PROVIDERS.DIGITAL_OCEAN]: DigitalOceanIcon, + [PROVIDERS.HETZNER]: HetznerIcon, +}); diff --git a/console/ui/src/entities/cluster-form-cloud-region-block/model/types.ts b/console/ui/src/entities/cluster-form-cloud-region-block/model/types.ts new file mode 100644 index 000000000..502161210 --- /dev/null +++ b/console/ui/src/entities/cluster-form-cloud-region-block/model/types.ts @@ -0,0 +1,5 @@ +import { DeploymentInfoCloudRegion } from '@shared/api/api/other.ts'; + +export interface CloudFormRegionBlockProps { + regions: DeploymentInfoCloudRegion[]; +} diff --git a/console/ui/src/entities/cluster-form-cloud-region-block/ui/index.tsx b/console/ui/src/entities/cluster-form-cloud-region-block/ui/index.tsx new file mode 100644 index 000000000..709eaf2f2 --- /dev/null +++ b/console/ui/src/entities/cluster-form-cloud-region-block/ui/index.tsx @@ -0,0 +1,81 @@ +import { FC, SyntheticEvent } 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 ClusterFormRegionConfigBox from '@widgets/cluster-form/ui/ClusterFormRegionConfigBox.tsx'; + +const CloudFormRegionBlock: FC = () => { + const { t } = useTranslation('clusters'); + const { control, watch, setValue } = useFormContext(); + + const watchProvider = watch(CLUSTER_FORM_FIELD_NAMES.PROVIDER); + const regionWatch = watch(CLUSTER_FORM_FIELD_NAMES.REGION); + + const regions = watchProvider?.cloud_regions ?? []; + + const handleRegionChange = + (onChange: (...event: never[]) => void) => (e: SyntheticEvent, value: string) => { + onChange(value); + setValue( + CLUSTER_FORM_FIELD_NAMES.REGION_CONFIG, + regions?.find((region) => region.code === value)?.datacenters?.[0], + ); + }; + + const handleRegionConfigChange = (onChange: (...event: never[]) => void, value: string) => () => { + onChange(value); + }; + + return ( + + + {t('selectCloudRegion')} + + + { + return ( + + {regions.map((region) => ( + + ))} + + ); + }} + /> + + { + return ( + <> + {regions.map((region) => ( + + + {region.datacenters.map((config) => ( + + ))} + + + ))} + + ); + }} + /> + + + ); +}; + +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 new file mode 100644 index 000000000..79a75cb09 --- /dev/null +++ b/console/ui/src/entities/cluster-form-cluster-name-block/index.ts @@ -0,0 +1,3 @@ +import ClusterFormClusterNameBlock from '@entities/cluster-form-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-form-cluster-name-block/ui/index.tsx new file mode 100644 index 000000000..2f7519643 --- /dev/null +++ b/console/ui/src/entities/cluster-form-cluster-name-block/ui/index.tsx @@ -0,0 +1,37 @@ +import React 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'; + +const ClusterFormClusterNameBlock: React.FC = () => { + const { t } = useTranslation('clusters'); + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + + + {t('clusterName')}* + + ( + + )} + /> + + ); +}; + +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 new file mode 100644 index 000000000..051571223 --- /dev/null +++ b/console/ui/src/entities/cluster-form-environment-block/index.ts @@ -0,0 +1,3 @@ +import ClusterFormEnvironmentBlock from '@entities/cluster-form-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-form-environment-block/model/types.ts new file mode 100644 index 000000000..3cf7df829 --- /dev/null +++ b/console/ui/src/entities/cluster-form-environment-block/model/types.ts @@ -0,0 +1,5 @@ +import { ResponseEnvironment } from '@shared/api/api/environments.ts'; + +export interface EnvironmentBlockProps { + environments: ResponseEnvironment[]; +} diff --git a/console/ui/src/entities/cluster-form-environment-block/ui/index.tsx b/console/ui/src/entities/cluster-form-environment-block/ui/index.tsx new file mode 100644 index 000000000..6b4286fb2 --- /dev/null +++ b/console/ui/src/entities/cluster-form-environment-block/ui/index.tsx @@ -0,0 +1,34 @@ +import { FC } from 'react'; +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'; + +const ClusterFormEnvironmentBlock: FC = ({ environments }) => { + const { t } = useTranslation('shared'); + const { control } = useFormContext(); + + return ( + + + {t('environment')} + + ( + + )} + /> + + ); +}; + +export default ClusterFormEnvironmentBlock; diff --git a/console/ui/src/entities/cluster-form-instances-amount-block/assets/instancesIcon.svg b/console/ui/src/entities/cluster-form-instances-amount-block/assets/instancesIcon.svg new file mode 100644 index 000000000..be31ac321 --- /dev/null +++ b/console/ui/src/entities/cluster-form-instances-amount-block/assets/instancesIcon.svg @@ -0,0 +1,9 @@ + + + + + + + + + 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 new file mode 100644 index 000000000..6bc1e3a93 --- /dev/null +++ b/console/ui/src/entities/cluster-form-instances-amount-block/index.ts @@ -0,0 +1,3 @@ +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 new file mode 100644 index 000000000..e8c64732e --- /dev/null +++ b/console/ui/src/entities/cluster-form-instances-amount-block/ui/index.tsx @@ -0,0 +1,42 @@ +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Controller, useFormContext } from 'react-hook-form'; +import { Box, Typography } 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/serversIcon.svg?react'; + +const InstancesAmountBlock: FC = () => { + const { t } = useTranslation('clusters'); + + 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 new file mode 100644 index 000000000..6fbdf71ba --- /dev/null +++ b/console/ui/src/entities/cluster-form-instances-block/index.ts @@ -0,0 +1,3 @@ +import CloudFormInstancesBlock from '@entities/cluster-form-instances-block/ui'; + +export default CloudFormInstancesBlock; diff --git a/console/ui/src/entities/cluster-form-instances-block/model/types.ts b/console/ui/src/entities/cluster-form-instances-block/model/types.ts new file mode 100644 index 000000000..b560ec09a --- /dev/null +++ b/console/ui/src/entities/cluster-form-instances-block/model/types.ts @@ -0,0 +1,9 @@ +import { DeploymentInstanceType } from '@shared/api/api/other.ts'; + +export interface CloudFormInstancesBlockProps { + instances: { + small?: DeploymentInstanceType[]; + medium?: DeploymentInstanceType[]; + large?: DeploymentInstanceType[]; + }; +} 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 new file mode 100644 index 000000000..3183228a5 --- /dev/null +++ b/console/ui/src/entities/cluster-form-instances-block/ui/index.tsx @@ -0,0 +1,79 @@ +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'); + 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 new file mode 100644 index 000000000..bbce068bb --- /dev/null +++ b/console/ui/src/entities/cluster-info/index.ts @@ -0,0 +1,3 @@ +import ClusterInfo from '@entities/cluster-info/ui'; + +export default ClusterInfo; diff --git a/console/ui/src/entities/cluster-info/lib/hooks.tsx b/console/ui/src/entities/cluster-info/lib/hooks.tsx new file mode 100644 index 000000000..599e76772 --- /dev/null +++ b/console/ui/src/entities/cluster-info/lib/hooks.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from 'react-i18next'; +import { Typography } from '@mui/material'; +import { ClusterInfoProps } from '@entities/cluster-info/model/types.ts'; + +export const useGetClusterInfoConfig = ({ + postgresVersion, + clusterName, + description, + environment, + location, +}: ClusterInfoProps) => { + const { t } = useTranslation(['clusters', 'shared']); + + return [ + { + title: t('postgresVersion', { ns: 'clusters' }), + children: {postgresVersion}, + }, + { + title: t('clusterName', { ns: 'clusters' }), + children: {clusterName}, + }, + { + title: t('description', { ns: 'shared' }), + children: {description ?? '---'}, + }, + { + title: t('environment', { ns: 'shared' }), + children: {environment}, + }, + ...(location + ? [ + { + title: t('location', { ns: 'clusters' }), + children: {location}, + }, + ] + : []), + ]; +}; diff --git a/console/ui/src/entities/cluster-info/model/types.ts b/console/ui/src/entities/cluster-info/model/types.ts new file mode 100644 index 000000000..ca8b3e36c --- /dev/null +++ b/console/ui/src/entities/cluster-info/model/types.ts @@ -0,0 +1,7 @@ +export interface ClusterInfoProps { + postgresVersion?: number; + clusterName?: string; + description?: string; + environment?: string; + location?: string; +} diff --git a/console/ui/src/entities/cluster-info/ui/index.tsx b/console/ui/src/entities/cluster-info/ui/index.tsx new file mode 100644 index 000000000..ca9b24e10 --- /dev/null +++ b/console/ui/src/entities/cluster-info/ui/index.tsx @@ -0,0 +1,34 @@ +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 EditNoteOutlinedIcon from '@mui/icons-material/EditNoteOutlined'; +import { useGetClusterInfoConfig } from '@entities/cluster-info/lib/hooks.tsx'; +import InfoCardBody from '@shared/ui/info-card-body'; + +const ClusterInfo: FC = ({ postgresVersion, clusterName, description, environment, location }) => { + const { t } = useTranslation(['clusters', 'shared']); + + const config = useGetClusterInfoConfig({ + postgresVersion, + clusterName, + description, + environment, + location, + }); + + return ( + + }> + + {t('clusterInfo')} + + + + + + ); +}; + +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 new file mode 100644 index 000000000..9c916eca5 --- /dev/null +++ b/console/ui/src/entities/cluster-instance-config-box/index.ts @@ -0,0 +1,3 @@ +import ClusterFromInstanceConfigBox from '@entities/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-instance-config-box/model/types.ts new file mode 100644 index 000000000..b02047b62 --- /dev/null +++ b/console/ui/src/entities/cluster-instance-config-box/model/types.ts @@ -0,0 +1,7 @@ +import { ClusterFormSelectableBoxProps } from '@shared/ui/selectable-box/model/types.ts'; + +export interface ClusterFromInstanceConfigBoxProps extends ClusterFormSelectableBoxProps { + name: string; + cpu: string; + ram: string; +} diff --git a/console/ui/src/entities/cluster-instance-config-box/ui/index.tsx b/console/ui/src/entities/cluster-instance-config-box/ui/index.tsx new file mode 100644 index 000000000..75fdad778 --- /dev/null +++ b/console/ui/src/entities/cluster-instance-config-box/ui/index.tsx @@ -0,0 +1,32 @@ +import { FC } from 'react'; +import { ClusterFromInstanceConfigBoxProps } from '@entities/cluster-instance-config-box/model/types.ts'; +import SelectableBox from '@shared/ui/selectable-box'; +import { Box, Stack, Typography } from '@mui/material'; +import RamIcon from '@shared/assets/ramIcon.svg?react'; +import CpuIcon from '@shared/assets/cpuIcon.svg?react'; + +const ClusterFromInstanceConfigBox: FC = ({ + name, + cpu, + ram, + isActive, + ...props +}) => ( + + + {name} + + + + + {cpu} CPU + + + + {ram} GB RAM + + + +); + +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 new file mode 100644 index 000000000..d150a3dc7 --- /dev/null +++ b/console/ui/src/entities/cluster-name-description-block/index.ts @@ -0,0 +1,3 @@ +import ClusterNameDescriptionBlock from '@entities/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-name-description-block/ui/index.tsx new file mode 100644 index 000000000..efef382dc --- /dev/null +++ b/console/ui/src/entities/cluster-name-description-block/ui/index.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Controller, useFormContext } from 'react-hook-form'; +import { Box, TextField, Typography } from '@mui/material'; +import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; + +const ClusterNameDescriptionBlock: React.FC = () => { + const { t } = useTranslation('clusters'); + const { control } = useFormContext(); + + return ( + + + {t('description')} + + ( + + )} + /> + + ); +}; + +export default ClusterNameDescriptionBlock; diff --git a/console/ui/src/entities/connection-info/assets/eyeIcon.svg b/console/ui/src/entities/connection-info/assets/eyeIcon.svg new file mode 100644 index 000000000..08e322d38 --- /dev/null +++ b/console/ui/src/entities/connection-info/assets/eyeIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/console/ui/src/entities/connection-info/index.ts b/console/ui/src/entities/connection-info/index.ts new file mode 100644 index 000000000..880b22c06 --- /dev/null +++ b/console/ui/src/entities/connection-info/index.ts @@ -0,0 +1,3 @@ +import ConnectionInfo from '@entities/connection-info/ui'; + +export default ConnectionInfo; diff --git a/console/ui/src/entities/connection-info/lib/hooks.tsx b/console/ui/src/entities/connection-info/lib/hooks.tsx new file mode 100644 index 000000000..3ab9bdd48 --- /dev/null +++ b/console/ui/src/entities/connection-info/lib/hooks.tsx @@ -0,0 +1,65 @@ +import { useTranslation } from 'react-i18next'; +import { Stack, Typography } from '@mui/material'; +import { useState } from 'react'; +import CopyIcon from '@shared/ui/copy-icon'; +import EyeIcon from '../assets/eyeIcon.svg?react'; +import ConnectionInfoRowContainer from '@entities/connection-info/ui/ConnectionInfoRowConteiner.tsx'; +import { ConnectionInfoProps } from '@entities/connection-info/model/types.ts'; + +export const useGetConnectionInfoConfig = ({ connectionInfo }: { connectionInfo: ConnectionInfoProps }) => { + const { t } = useTranslation(['clusters', 'shared']); + const [isPasswordHidden, setIsPasswordHidden] = useState(true); + + const togglePasswordVisibility = () => setIsPasswordHidden((prev) => !prev); + + const renderCollection = (collection: string | object, defaultLabel: string) => { + return ['string', 'number'].includes(typeof collection) + ? [ + { + title: defaultLabel, + children: ( + + {collection} + + ), + }, + ] + : typeof collection === 'object' + ? Object.entries(collection)?.map(([key, value]) => ({ + title: `${defaultLabel} ${key}`, + children: ( + + {value} + + ), + })) ?? [] + : []; + }; + + return [ + ...(connectionInfo?.address ? renderCollection(connectionInfo.address, t('address', { ns: 'shared' })) : []), + ...(connectionInfo?.port ? renderCollection(connectionInfo.port, t('port', { ns: 'clusters' })) : []), + { + title: t('user', { ns: 'shared' }), + children: ( + + {connectionInfo?.superuser} + + ), + }, + { + title: t('password', { ns: 'shared' }), + children: ( + + + {isPasswordHidden ? connectionInfo?.password?.replace(/./g, '*') : connectionInfo?.password} + + + + + + + ), + }, + ]; +}; diff --git a/console/ui/src/entities/connection-info/model/types.ts b/console/ui/src/entities/connection-info/model/types.ts new file mode 100644 index 000000000..13a0db1a0 --- /dev/null +++ b/console/ui/src/entities/connection-info/model/types.ts @@ -0,0 +1,14 @@ +import { ReactNode } from 'react'; + +export interface ConnectionInfoProps { + connectionInfo?: { + address?: string | Record; + port?: string | Record; + superuser?: string; + password?: string; + }; +} + +export interface ConnectionInfoRowContainerProps { + children: ReactNode; +} diff --git a/console/ui/src/entities/connection-info/ui/ConnectionInfoRowConteiner.tsx b/console/ui/src/entities/connection-info/ui/ConnectionInfoRowConteiner.tsx new file mode 100644 index 000000000..2b2214e2b --- /dev/null +++ b/console/ui/src/entities/connection-info/ui/ConnectionInfoRowConteiner.tsx @@ -0,0 +1,13 @@ +import { FC } from 'react'; +import { Stack } from '@mui/material'; +import { ConnectionInfoRowContainerProps } from '@entities/connection-info/model/types.ts'; + +const ConnectionInfoRowContainer: FC = ({ children }) => { + return ( + + {children} + + ); +}; + +export default ConnectionInfoRowContainer; diff --git a/console/ui/src/entities/connection-info/ui/index.tsx b/console/ui/src/entities/connection-info/ui/index.tsx new file mode 100644 index 000000000..e15e1797d --- /dev/null +++ b/console/ui/src/entities/connection-info/ui/index.tsx @@ -0,0 +1,28 @@ +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 PowerOutlinedIcon from '@mui/icons-material/PowerOutlined'; +import { useGetConnectionInfoConfig } from '@entities/connection-info/lib/hooks.tsx'; +import InfoCardBody from '@shared/ui/info-card-body'; + +const ConnectionInfo: FC = ({ connectionInfo }) => { + const { t } = useTranslation(['clusters', 'shared']); + + const config = useGetConnectionInfoConfig({ connectionInfo }); + + return ( + + }> + + {t('connectionInfo')} + + + + + + ); +}; + +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 new file mode 100644 index 000000000..b9a1caa2e --- /dev/null +++ b/console/ui/src/entities/database-servers-block/index.ts @@ -0,0 +1,3 @@ +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 new file mode 100644 index 000000000..08dd769ca --- /dev/null +++ b/console/ui/src/entities/database-servers-block/model/types.ts @@ -0,0 +1,6 @@ +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 new file mode 100644 index 000000000..074907f70 --- /dev/null +++ b/console/ui/src/entities/database-servers-block/ui/DatabaseServerBox.tsx @@ -0,0 +1,81 @@ +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/database-servers-block/ui/index.tsx b/console/ui/src/entities/database-servers-block/ui/index.tsx new file mode 100644 index 000000000..570d9709f --- /dev/null +++ b/console/ui/src/entities/database-servers-block/ui/index.tsx @@ -0,0 +1,40 @@ +import { FC } from 'react'; +import { 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 { Box, Button, Stack, Typography } from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import { useTranslation } from 'react-i18next'; + +const DatabaseServersBlock: FC = () => { + const { t } = useTranslation('clusters'); + const { fields, append, remove } = useFieldArray({ + name: CLUSTER_FORM_FIELD_NAMES.DATABASE_SERVERS, + }); + + const removeServer = (index: number) => () => remove(index); + + return ( + + + {t('databaseServers')} + + + + {fields.map((field, index) => ( + + ))} + + + + + ); +}; + +export default DatabaseServersBlock; diff --git a/console/ui/src/entities/load-balancers-block/index.ts b/console/ui/src/entities/load-balancers-block/index.ts new file mode 100644 index 000000000..3006f3b89 --- /dev/null +++ b/console/ui/src/entities/load-balancers-block/index.ts @@ -0,0 +1,3 @@ +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 new file mode 100644 index 000000000..5295688e0 --- /dev/null +++ b/console/ui/src/entities/load-balancers-block/ui/index.tsx @@ -0,0 +1,32 @@ +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 new file mode 100644 index 000000000..d11dc47b0 --- /dev/null +++ b/console/ui/src/entities/postgres-version-block/index.ts @@ -0,0 +1,3 @@ +import PostgresVersionBox from '@entities/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/postgres-version-block/model/types.ts new file mode 100644 index 000000000..845ecc4c8 --- /dev/null +++ b/console/ui/src/entities/postgres-version-block/model/types.ts @@ -0,0 +1,5 @@ +import { ResponsePostgresVersion } from '@shared/api/api/other.ts'; + +export interface PostgresVersionBlockProps { + postgresVersions: ResponsePostgresVersion[]; +} diff --git a/console/ui/src/entities/postgres-version-block/ui/index.tsx b/console/ui/src/entities/postgres-version-block/ui/index.tsx new file mode 100644 index 000000000..23bc1a952 --- /dev/null +++ b/console/ui/src/entities/postgres-version-block/ui/index.tsx @@ -0,0 +1,43 @@ +import { FC } from 'react'; +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'; + +const PostgresVersionBox: FC = ({ postgresVersions }) => { + const { t } = useTranslation('clusters'); + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + + + {t('postgresVersion')} + + ( + + )} + /> + + ); +}; + +export default PostgresVersionBox; diff --git a/console/ui/src/entities/providers-block/index.ts b/console/ui/src/entities/providers-block/index.ts new file mode 100644 index 000000000..7c5ed31e0 --- /dev/null +++ b/console/ui/src/entities/providers-block/index.ts @@ -0,0 +1,3 @@ +import ClusterFormProvidersBlock from '@entities/providers-block/ui'; + +export default ClusterFormProvidersBlock; diff --git a/console/ui/src/entities/providers-block/model/types.ts b/console/ui/src/entities/providers-block/model/types.ts new file mode 100644 index 000000000..cf51b516b --- /dev/null +++ b/console/ui/src/entities/providers-block/model/types.ts @@ -0,0 +1,10 @@ +import { ReactElement } from 'react'; + +export interface ProvidersBlockProps { + providers: { code?: string; description?: string }[]; +} + +export interface ClusterFormCloudProviderBoxProps { + children?: ReactElement; + isActive?: boolean; +} diff --git a/console/ui/src/entities/providers-block/ui/ClusterFormCloudProviderBox.tsx b/console/ui/src/entities/providers-block/ui/ClusterFormCloudProviderBox.tsx new file mode 100644 index 000000000..eb86ba3bb --- /dev/null +++ b/console/ui/src/entities/providers-block/ui/ClusterFormCloudProviderBox.tsx @@ -0,0 +1,23 @@ +import { FC } from 'react'; +import SelectableBox from '@shared/ui/selectable-box'; +import { ClusterFormCloudProviderBoxProps } from '@entities/providers-block/model/types.ts'; + +const ClusterFormCloudProviderBox: FC = ({ children, isActive, ...props }) => { + return ( + + {children} + + ); +}; + +export default ClusterFormCloudProviderBox; diff --git a/console/ui/src/entities/providers-block/ui/index.tsx b/console/ui/src/entities/providers-block/ui/index.tsx new file mode 100644 index 000000000..bbecb6df2 --- /dev/null +++ b/console/ui/src/entities/providers-block/ui/index.tsx @@ -0,0 +1,81 @@ +import { FC } from 'react'; +import { Box, Stack, Tooltip, Typography } 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 ErrorOutlineOutlinedIcon from '@mui/icons-material/ErrorOutlineOutlined'; +import ServersIcon from '@shared/assets/serversIcon.svg?react'; +import theme from '@shared/theme/theme.ts'; +import { ProvidersBlockProps } from '@entities/providers-block/model/types.ts'; +import { PROVIDERS } from '@shared/config/constants.ts'; +import ClusterFormCloudProviderBox from '@entities/providers-block/ui/ClusterFormCloudProviderBox.tsx'; + +const ClusterFormProvidersBlock: FC = ({ providers }) => { + const { t } = useTranslation('clusters'); + const { control, reset } = useFormContext(); + + const nameIconProvidersMap = useNameIconProvidersMap(); + + const handleProviderChange = (value) => () => { + reset((values) => ({ + ...values, + [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', + [CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]: value?.instance_types?.small?.[0], + [CLUSTER_FORM_FIELD_NAMES.STORAGE_AMOUNT]: + value?.volumes?.find((volume) => volume?.is_default)?.min_size < 100 + ? 100 + : value?.volumes?.find((volume) => volume?.is_default)?.min_size, + })); + }; + + return ( + + + {t('selectDeploymentDestination')} + + ( + + {providers.map((provider) => ( + + {provider.description} + + ))} + + + + + + + + + + {t('yourOwn')} + + + {t('machines')} + + + + + + + )} + /> + + ); +}; + +export default ClusterFormProvidersBlock; diff --git a/console/ui/src/entities/secret-form-block/index.ts b/console/ui/src/entities/secret-form-block/index.ts new file mode 100644 index 000000000..f2fd1ffa4 --- /dev/null +++ b/console/ui/src/entities/secret-form-block/index.ts @@ -0,0 +1,3 @@ +import SecretFormBlock from '@entities/secret-form-block/ui'; + +export default SecretFormBlock; diff --git a/console/ui/src/entities/secret-form-block/lib/functions.ts b/console/ui/src/entities/secret-form-block/lib/functions.ts new file mode 100644 index 000000000..b00615d25 --- /dev/null +++ b/console/ui/src/entities/secret-form-block/lib/functions.ts @@ -0,0 +1,118 @@ +import { PROVIDERS } from '@shared/config/constants.ts'; +import AwsSecretBlock from '@entities/secret-form-block/ui/AwsSecret.tsx'; +import GcpSecretBlock from '@entities/secret-form-block/ui/GcpSecret.tsx'; +import AzureSecretBlock from '@entities/secret-form-block/ui/AzureSecret.tsx'; +import DoSecretBlock from '@entities/secret-form-block/ui/DigitalOceanSecret.tsx'; +import HetznerSecretBlock from '@entities/secret-form-block/ui/HetznerSecret.tsx'; +import SshKeySecretBlock from '@entities/secret-form-block/ui/SshKeySecret.tsx'; +import PasswordSecretBlock from '@entities/secret-form-block/ui/PasswordSecret.tsx'; +import { AUTHENTICATION_METHODS } from '@shared/model/constants.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'; + +export const getAddSecretFormContentByType = (type: string) => { + switch (type) { + case PROVIDERS.AWS: + return { + translationKey: 'settingsAwsSecretInfo', + link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html', + formComponent: AwsSecretBlock, + }; + case PROVIDERS.GCP: + return { + translationKey: 'settingsGcpSecretInfo', + link: 'https://cloud.google.com/iam/docs/keys-create-delete', + formComponent: GcpSecretBlock, + }; + case PROVIDERS.AZURE: + return { + translationKey: 'settingsAzureSecretInfo', + link: 'https://learn.microsoft.com/en-us/azure/developer/ansible/create-ansible-service-principal?tabs=azure-cli', + formComponent: AzureSecretBlock, + }; + case PROVIDERS.DIGITAL_OCEAN: + return { + translationKey: 'settingsDoSecretInfo', + link: 'https://docs.digitalocean.com/reference/api/create-personal-access-token/', + formComponent: DoSecretBlock, + }; + case PROVIDERS.HETZNER: + return { + translationKey: 'settingsHetznerSecretInfo', + link: 'https://docs.hetzner.com/cloud/api/getting-started/generating-api-token/', + formComponent: HetznerSecretBlock, + }; + case AUTHENTICATION_METHODS.SSH: + return { + translationKey: 'settingsSshKeySecretInfo', + formComponent: SshKeySecretBlock, + }; + default: + return { + translationKey: 'settingsPasswordSecretInfo', + formComponent: PasswordSecretBlock, + }; + } +}; + +export const getSecretBodyFromValues = (values: SecretFormValues) => { + switch (values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SECRET_TYPE]) { + case PROVIDERS.AWS: + return { + [PROVIDERS.AWS]: { + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AWS_ACCESS_KEY_ID]: + values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AWS_ACCESS_KEY_ID], + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AWS_SECRET_ACCESS_KEY]: + values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AWS_SECRET_ACCESS_KEY], + }, + }; + case PROVIDERS.GCP: + return { + [PROVIDERS.GCP]: { + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.GCP_SERVICE_ACCOUNT_CONTENTS]: + values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.GCP_SERVICE_ACCOUNT_CONTENTS], + }, + }; + case PROVIDERS.DIGITAL_OCEAN: + return { + [PROVIDERS.DIGITAL_OCEAN]: { + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.DO_API_TOKEN]: + values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.DO_API_TOKEN], + }, + }; + case PROVIDERS.AZURE: + return { + [PROVIDERS.AZURE]: { + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AZURE_SUBSCRIPTION_ID]: + values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AZURE_SUBSCRIPTION_ID], + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AZURE_CLIENT_ID]: + values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AZURE_CLIENT_ID], + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AZURE_SECRET]: + values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AZURE_SECRET], + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AZURE_TENANT]: + values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AZURE_TENANT], + }, + }; + case PROVIDERS.HETZNER: + return { + [PROVIDERS.HETZNER]: { + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.HCLOUD_API_TOKEN]: + values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.HCLOUD_API_TOKEN], + }, + }; + case AUTHENTICATION_METHODS.SSH: + return { + [AUTHENTICATION_METHODS.SSH]: { + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SSH_PRIVATE_KEY]: + values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SSH_PRIVATE_KEY], + }, + }; + case AUTHENTICATION_METHODS.PASSWORD: + return { + [AUTHENTICATION_METHODS.PASSWORD]: { + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.USERNAME]: values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.USERNAME], + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.PASSWORD]: values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.PASSWORD], + }, + }; + } +}; diff --git a/console/ui/src/entities/secret-form-block/model/constants.ts b/console/ui/src/entities/secret-form-block/model/constants.ts new file mode 100644 index 000000000..dcb23babe --- /dev/null +++ b/console/ui/src/entities/secret-form-block/model/constants.ts @@ -0,0 +1,30 @@ +export const SECRET_MODAL_CONTENT_CLOUD_PROVIDERS_FORM_FIELD_NAMES = Object.freeze({ + // changing names or keys might break 'secrets' POST request + AWS_ACCESS_KEY_ID: 'AWS_ACCESS_KEY_ID', + AWS_SECRET_ACCESS_KEY: 'AWS_SECRET_ACCESS_KEY', + GCP_SERVICE_ACCOUNT_CONTENTS: 'GCP_SERVICE_ACCOUNT_CONTENTS', + DO_API_TOKEN: 'DO_API_TOKEN', + AZURE_SUBSCRIPTION_ID: 'AZURE_SUBSCRIPTION_ID', + AZURE_CLIENT_ID: 'AZURE_CLIENT_ID', + AZURE_SECRET: 'AZURE_SECRET', + AZURE_TENANT: 'AZURE_TENANT', + HCLOUD_API_TOKEN: 'HCLOUD_API_TOKEN', +}); + +export const SECRET_MODAL_CONTENT_LOCAL_FORM_FIELDS = Object.freeze({ + SSH_PRIVATE_KEY: 'SSH_PRIVATE_KEY', + USERNAME: 'USERNAME', + PASSWORD: 'PASSWORD', +}); + +export const SECRET_MODAL_CONTENT_BODY_FORM_FIELDS = Object.freeze({ + ...SECRET_MODAL_CONTENT_CLOUD_PROVIDERS_FORM_FIELD_NAMES, + ...SECRET_MODAL_CONTENT_LOCAL_FORM_FIELDS, +}); + +export const SECRET_MODAL_CONTENT_FORM_FIELD_NAMES = Object.freeze({ + // changing names might break 'secrets' POST request + SECRET_TYPE: 'type', + SECRET_NAME: 'name', + ...SECRET_MODAL_CONTENT_BODY_FORM_FIELDS, +}); diff --git a/console/ui/src/entities/secret-form-block/model/types.ts b/console/ui/src/entities/secret-form-block/model/types.ts new file mode 100644 index 000000000..5657eee4c --- /dev/null +++ b/console/ui/src/entities/secret-form-block/model/types.ts @@ -0,0 +1,28 @@ +import { PROVIDERS } from '@shared/config/constants.ts'; +import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; + +export interface SecretFormBlockProps { + secretType: (typeof PROVIDERS)[keyof typeof PROVIDERS]; + isAdditionalInfoDisplayed?: boolean; +} + +export interface SecretFormValues { + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SECRET_TYPE]: string; + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SECRET_NAME]: string; + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AWS_ACCESS_KEY_ID]?: string; + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AWS_SECRET_ACCESS_KEY]?: string; + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.GCP_SERVICE_ACCOUNT_CONTENTS]?: string; + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.DO_API_TOKEN]?: string; + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AZURE_SUBSCRIPTION_ID]?: string; + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AZURE_CLIENT_ID]?: string; + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AZURE_SECRET]?: string; + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AZURE_TENANT]?: string; + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.HCLOUD_API_TOKEN]?: string; + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SSH_PRIVATE_KEY]?: string; + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.USERNAME]?: string; + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.PASSWORD]?: string; +} + +export interface SecretModalContentProps { + secretType: string; +} diff --git a/console/ui/src/entities/secret-form-block/ui/AwsSecret.tsx b/console/ui/src/entities/secret-form-block/ui/AwsSecret.tsx new file mode 100644 index 000000000..a6f4c1653 --- /dev/null +++ b/console/ui/src/entities/secret-form-block/ui/AwsSecret.tsx @@ -0,0 +1,49 @@ +import { FC } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { Stack, TextField } from '@mui/material'; + +import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; + +const AwsSecretBlock: FC = () => { + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + + ( + + )} + /> + ( + + )} + /> + + ); +}; + +export default AwsSecretBlock; diff --git a/console/ui/src/entities/secret-form-block/ui/AzureSecret.tsx b/console/ui/src/entities/secret-form-block/ui/AzureSecret.tsx new file mode 100644 index 000000000..e0cc1ec2d --- /dev/null +++ b/console/ui/src/entities/secret-form-block/ui/AzureSecret.tsx @@ -0,0 +1,79 @@ +import { FC } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { Stack, TextField } from '@mui/material'; + +import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; + +const AzureSecretBlock: FC = () => { + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + ); +}; + +export default AzureSecretBlock; diff --git a/console/ui/src/entities/secret-form-block/ui/DigitalOceanSecret.tsx b/console/ui/src/entities/secret-form-block/ui/DigitalOceanSecret.tsx new file mode 100644 index 000000000..92406140d --- /dev/null +++ b/console/ui/src/entities/secret-form-block/ui/DigitalOceanSecret.tsx @@ -0,0 +1,34 @@ +import { FC } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { Stack, TextField } from '@mui/material'; + +import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; + +const DoSecretBlock: FC = () => { + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + + ( + + )} + /> + + ); +}; + +export default DoSecretBlock; diff --git a/console/ui/src/entities/secret-form-block/ui/GcpSecret.tsx b/console/ui/src/entities/secret-form-block/ui/GcpSecret.tsx new file mode 100644 index 000000000..b3bdc4824 --- /dev/null +++ b/console/ui/src/entities/secret-form-block/ui/GcpSecret.tsx @@ -0,0 +1,36 @@ +import { FC } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { Stack, TextField } from '@mui/material'; + +import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; + +const GcpSecretBlock: FC = () => { + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + + ( + + )} + /> + + ); +}; + +export default GcpSecretBlock; diff --git a/console/ui/src/entities/secret-form-block/ui/HetznerSecret.tsx b/console/ui/src/entities/secret-form-block/ui/HetznerSecret.tsx new file mode 100644 index 000000000..1eae92059 --- /dev/null +++ b/console/ui/src/entities/secret-form-block/ui/HetznerSecret.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { Stack, TextField } from '@mui/material'; + +import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; + +const HetznerSecretBlock: React.FC = () => { + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + + ( + + )} + /> + + ); +}; + +export default HetznerSecretBlock; diff --git a/console/ui/src/entities/secret-form-block/ui/PasswordSecret.tsx b/console/ui/src/entities/secret-form-block/ui/PasswordSecret.tsx new file mode 100644 index 000000000..2127a1cdc --- /dev/null +++ b/console/ui/src/entities/secret-form-block/ui/PasswordSecret.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Controller, useFormContext } from 'react-hook-form'; +import { Stack, TextField } from '@mui/material'; + +import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; + +const PasswordSecretBlock: React.FC = () => { + const { t } = useTranslation('shared'); + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + + ( + + )} + /> + ( + + )} + /> + + ); +}; + +export default PasswordSecretBlock; diff --git a/console/ui/src/entities/secret-form-block/ui/SshKeySecret.tsx b/console/ui/src/entities/secret-form-block/ui/SshKeySecret.tsx new file mode 100644 index 000000000..88dc63ca2 --- /dev/null +++ b/console/ui/src/entities/secret-form-block/ui/SshKeySecret.tsx @@ -0,0 +1,38 @@ +import { FC } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { Stack, TextField } from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; + +const SshKeySecretBlock: FC = () => { + const { t } = useTranslation('settings'); + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + + ( + + )} + /> + + ); +}; + +export default SshKeySecretBlock; diff --git a/console/ui/src/entities/secret-form-block/ui/index.tsx b/console/ui/src/entities/secret-form-block/ui/index.tsx new file mode 100644 index 000000000..8bdf97780 --- /dev/null +++ b/console/ui/src/entities/secret-form-block/ui/index.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { getAddSecretFormContentByType } from '@entities/secret-form-block/lib/functions.ts'; +import { Link, Stack, Typography } from '@mui/material'; +import { SecretFormBlockProps } from '@entities/secret-form-block/model/types.ts'; + +const SecretFormBlock: React.FC = ({ secretType, isAdditionalInfoDisplayed = false }) => { + const { t } = useTranslation('settings'); + + const content = getAddSecretFormContentByType(secretType); + + return ( + + + {content.link ? ( + + + + ) : ( + t(content.translationKey) + )} + + + {isAdditionalInfoDisplayed ? ( + {t('settingsConfidentialDataStore')} + ) : null} + + ); +}; +export default SecretFormBlock; 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 000000000..f1e0d2d02 --- /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 new file mode 100644 index 000000000..c87aad173 --- /dev/null +++ b/console/ui/src/entities/settings-proxy-block/model/constants.ts @@ -0,0 +1,4 @@ +export const SETTINGS_FORM_FIELDS_NAMES = Object.freeze({ + HTTP_PROXY: 'http_proxy', + HTTPS_PROXY: 'https_proxy', +}); 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 000000000..9217a87b8 --- /dev/null +++ b/console/ui/src/entities/settings-proxy-block/model/types.ts @@ -0,0 +1,6 @@ +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 new file mode 100644 index 000000000..6ae6ccd74 --- /dev/null +++ b/console/ui/src/entities/settings-proxy-block/ui/index.tsx @@ -0,0 +1,44 @@ +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/sidebar-item/index.ts b/console/ui/src/entities/sidebar-item/index.ts new file mode 100644 index 000000000..cdfcd55a4 --- /dev/null +++ b/console/ui/src/entities/sidebar-item/index.ts @@ -0,0 +1,3 @@ +import SidebarItem from './ui'; + +export default SidebarItem; diff --git a/console/ui/src/entities/sidebar-item/model/types.ts b/console/ui/src/entities/sidebar-item/model/types.ts new file mode 100644 index 000000000..91a81cae3 --- /dev/null +++ b/console/ui/src/entities/sidebar-item/model/types.ts @@ -0,0 +1,8 @@ +export interface SidebarItemProps { + path: string; + label: string; + icon?: Element; + isActive?: string; + 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 new file mode 100644 index 000000000..1645c0890 --- /dev/null +++ b/console/ui/src/entities/sidebar-item/ui/SidebarItemContent.tsx @@ -0,0 +1,36 @@ +import { FC } from 'react'; +import { Link } from 'react-router-dom'; +import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; +import theme from '@shared/theme/theme.ts'; +import { SidebarItemProps } from '@entities/sidebar-item/model/types.ts'; + +const SidebarItemContent: FC = ({ + path, + label, + icon: SidebarIcon, + isActive, + target, + isCollapsed, +}) => { + return ( + + + {SidebarIcon ? : null} + + {!isCollapsed ? : null} + + ); +}; + +export default SidebarItemContent; diff --git a/console/ui/src/entities/sidebar-item/ui/index.tsx b/console/ui/src/entities/sidebar-item/ui/index.tsx new file mode 100644 index 000000000..0231b2ec1 --- /dev/null +++ b/console/ui/src/entities/sidebar-item/ui/index.tsx @@ -0,0 +1,36 @@ +import { FC } from 'react'; +import { SidebarItemProps } from '../model/types.ts'; +import { Box, ListItem, Tooltip, useTheme } from '@mui/material'; +import SidebarItemContent from '@entities/sidebar-item/ui/SidebarItemContent.tsx'; + +const SidebarItem: FC = ({ path, label, icon, isActive, isCollapsed = false, target, ...props }) => { + const theme = useTheme(); + + return ( + + {isCollapsed ? ( + + + + + + ) : ( + + )} + + ); +}; + +export default SidebarItem; diff --git a/console/ui/src/entities/ssh-key-block/index.ts b/console/ui/src/entities/ssh-key-block/index.ts new file mode 100644 index 000000000..f9d2e7755 --- /dev/null +++ b/console/ui/src/entities/ssh-key-block/index.ts @@ -0,0 +1,3 @@ +import ClusterFormSshKeyBlock from '@entities/ssh-key-block/ui'; + +export default ClusterFormSshKeyBlock; diff --git a/console/ui/src/entities/ssh-key-block/ui/index.tsx b/console/ui/src/entities/ssh-key-block/ui/index.tsx new file mode 100644 index 000000000..0944603cc --- /dev/null +++ b/console/ui/src/entities/ssh-key-block/ui/index.tsx @@ -0,0 +1,40 @@ +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'; + +const ClusterFormSshKeyBlock: FC = () => { + const { t } = useTranslation('clusters'); + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + + + {t('sshPublicKey')}* + + ( + + )} + /> + + ); +}; + +export default ClusterFormSshKeyBlock; diff --git a/console/ui/src/entities/storage-block/index.ts b/console/ui/src/entities/storage-block/index.ts new file mode 100644 index 000000000..ca4917b81 --- /dev/null +++ b/console/ui/src/entities/storage-block/index.ts @@ -0,0 +1,3 @@ +import StorageBlock from '@entities/storage-block/ui'; + +export default StorageBlock; diff --git a/console/ui/src/entities/storage-block/lib/functions.ts b/console/ui/src/entities/storage-block/lib/functions.ts new file mode 100644 index 000000000..e69de29bb diff --git a/console/ui/src/entities/storage-block/ui/index.tsx b/console/ui/src/entities/storage-block/ui/index.tsx new file mode 100644 index 000000000..47b93cbf9 --- /dev/null +++ b/console/ui/src/entities/storage-block/ui/index.tsx @@ -0,0 +1,50 @@ +import { FC } from 'react'; +import { Box, Typography } from '@mui/material'; +import ClusterSliderBox from '@shared/ui/slider-box'; +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 StorageIcon from '@shared/assets/storageIcon.svg?react'; + +const StorageBlock: FC = () => { + const { t } = useTranslation('clusters'); + + 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 new file mode 100644 index 000000000..ac25bc850 --- /dev/null +++ b/console/ui/src/entities/vip-address-block/index.ts @@ -0,0 +1,3 @@ +import VipAddressBlock from '@entities/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/vip-address-block/ui/index.tsx new file mode 100644 index 000000000..2bf7aebf7 --- /dev/null +++ b/console/ui/src/entities/vip-address-block/ui/index.tsx @@ -0,0 +1,38 @@ +import React, { 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'; + +const VipAddressBlock: FC = () => { + const { t } = useTranslation('clusters'); + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + + + {t('clusterVipAddress')} + + ( + + )} + /> + + ); +}; + +export default VipAddressBlock; diff --git a/console/ui/src/features/add-environment/index.ts b/console/ui/src/features/add-environment/index.ts new file mode 100644 index 000000000..4cd74ca65 --- /dev/null +++ b/console/ui/src/features/add-environment/index.ts @@ -0,0 +1,3 @@ +import AddEnvironment from '@features/add-environment/ui'; + +export default AddEnvironment; diff --git a/console/ui/src/features/add-environment/ui/index.tsx b/console/ui/src/features/add-environment/ui/index.tsx new file mode 100644 index 000000000..e4d746bc6 --- /dev/null +++ b/console/ui/src/features/add-environment/ui/index.tsx @@ -0,0 +1,41 @@ +import React, { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import SettingsAddEntity from '@shared/ui/settings-add-entity/ui'; +import { usePostEnvironmentsMutation } from '@shared/api/api/environments.ts'; +import { AddEntityFormValues } from '@shared/ui/settings-add-entity/model/types.ts'; +import { ADD_ENTITY_FORM_NAMES } from '@shared/ui/settings-add-entity/model/constants.ts'; + +const AddEnvironment: FC = () => { + const { t } = useTranslation('settings'); + + const [postEnvironmentTrigger, postEnvironmentTriggerState] = usePostEnvironmentsMutation(); + + const onSubmit = async (values: AddEntityFormValues) => { + await postEnvironmentTrigger({ + requestEnvironment: { + name: values[ADD_ENTITY_FORM_NAMES.NAME], + description: values[ADD_ENTITY_FORM_NAMES.DESCRIPTION], + }, + }).unwrap(); + toast.success( + t('environmentSuccessfullyCreated', { + ns: 'toasts', + environmentName: values[ADD_ENTITY_FORM_NAMES.NAME], + }), + ); + }; + + return ( + + ); +}; + +export default AddEnvironment; diff --git a/console/ui/src/features/add-project/index.ts b/console/ui/src/features/add-project/index.ts new file mode 100644 index 000000000..5b56920db --- /dev/null +++ b/console/ui/src/features/add-project/index.ts @@ -0,0 +1,3 @@ +import AddProject from '@features/add-project/ui'; + +export default AddProject; diff --git a/console/ui/src/features/add-project/model/constants.ts b/console/ui/src/features/add-project/model/constants.ts new file mode 100644 index 000000000..e7ca72c01 --- /dev/null +++ b/console/ui/src/features/add-project/model/constants.ts @@ -0,0 +1,4 @@ +export const PROJECT_FORM_NAMES = Object.freeze({ + NAME: 'name', + DESCRIPTION: 'description', +}); diff --git a/console/ui/src/features/add-project/model/types.ts b/console/ui/src/features/add-project/model/types.ts new file mode 100644 index 000000000..427c0c177 --- /dev/null +++ b/console/ui/src/features/add-project/model/types.ts @@ -0,0 +1,6 @@ +import { PROJECT_FORM_NAMES } from '@features/add-project/model/constants.ts'; + +export interface ProjectFormValues { + [PROJECT_FORM_NAMES.NAME]: string; + [PROJECT_FORM_NAMES.NAME]: string; +} diff --git a/console/ui/src/features/add-project/model/validation.ts b/console/ui/src/features/add-project/model/validation.ts new file mode 100644 index 000000000..0ecdb5d9f --- /dev/null +++ b/console/ui/src/features/add-project/model/validation.ts @@ -0,0 +1,9 @@ +import * as yup from 'yup'; +import { TFunction } from 'i18next'; +import { PROJECT_FORM_NAMES } from '@features/add-project/model/constants.ts'; + +export const AddProjectFormSchema = (t: TFunction) => + yup.object({ + [PROJECT_FORM_NAMES.NAME]: yup.string().required(t('requiredField', { ns: 'validation' })), + [PROJECT_FORM_NAMES.DESCRIPTION]: yup.string(), + }); diff --git a/console/ui/src/features/add-project/ui/index.tsx b/console/ui/src/features/add-project/ui/index.tsx new file mode 100644 index 000000000..18ae968d8 --- /dev/null +++ b/console/ui/src/features/add-project/ui/index.tsx @@ -0,0 +1,54 @@ +import React, { FC } from 'react'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { usePostProjectsMutation } from '@shared/api/api/projects.ts'; +import { yupResolver } from '@hookform/resolvers/yup'; + +import { ProjectFormValues } from '@features/add-project/model/types.ts'; +import { toast } from 'react-toastify'; +import { AddProjectFormSchema } from '@features/add-project/model/validation.ts'; +import SettingsAddEntity from '@shared/ui/settings-add-entity/ui'; +import { ADD_ENTITY_FORM_NAMES } from '@shared/ui/settings-add-entity/model/constants.ts'; + +const AddProject: FC = () => { + const { t } = useTranslation(['settings', 'toasts']); + + const [postProjectTrigger, postProjectTriggerState] = usePostProjectsMutation(); + + const { + control, + handleSubmit, + formState: { isValid, isSubmitting }, + } = useForm({ + mode: 'all', + resolver: yupResolver(AddProjectFormSchema(t)), + }); + + const onSubmit = async (values: ProjectFormValues) => { + await postProjectTrigger({ + requestProjectCreate: { + name: values[ADD_ENTITY_FORM_NAMES.NAME], + description: values[ADD_ENTITY_FORM_NAMES.DESCRIPTION], + }, + }).unwrap(); + toast.success( + t('projectSuccessfullyCreated', { + ns: 'toasts', + projectName: values[ADD_ENTITY_FORM_NAMES.NAME], + }), + ); + }; + + return ( + + ); +}; + +export default AddProject; diff --git a/console/ui/src/features/add-secret/index.ts b/console/ui/src/features/add-secret/index.ts new file mode 100644 index 000000000..bb2061a68 --- /dev/null +++ b/console/ui/src/features/add-secret/index.ts @@ -0,0 +1,3 @@ +import SettingsAddSecret from '@features/add-secret/ui'; + +export default SettingsAddSecret; diff --git a/console/ui/src/features/add-secret/model/constants.ts b/console/ui/src/features/add-secret/model/constants.ts new file mode 100644 index 000000000..3001d4304 --- /dev/null +++ b/console/ui/src/features/add-secret/model/constants.ts @@ -0,0 +1,6 @@ +import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; + +export const ADD_SECRET_FORM_FIELD_NAMES = Object.freeze({ + SECRET_NAME: 'secretName', + ...SECRET_MODAL_CONTENT_FORM_FIELD_NAMES, +}); diff --git a/console/ui/src/features/add-secret/model/types.ts b/console/ui/src/features/add-secret/model/types.ts new file mode 100644 index 000000000..750b1b2e0 --- /dev/null +++ b/console/ui/src/features/add-secret/model/types.ts @@ -0,0 +1,7 @@ +import { ADD_SECRET_FORM_FIELD_NAMES } from '@features/add-secret/model/constants.ts'; + +import { SecretFormValues } from '@entities/secret-form-block/model/types.ts'; + +export interface AddSecretFormValues extends SecretFormValues { + [ADD_SECRET_FORM_FIELD_NAMES.SECRET_NAME]: string; +} diff --git a/console/ui/src/features/add-secret/model/validation.ts b/console/ui/src/features/add-secret/model/validation.ts new file mode 100644 index 000000000..a03daa589 --- /dev/null +++ b/console/ui/src/features/add-secret/model/validation.ts @@ -0,0 +1,49 @@ +import * as yup from 'yup'; +import { TFunction } from 'i18next'; +import { PROVIDERS } from '@shared/config/constants.ts'; + +import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; + +const requiredField = ({ valueToBeRequired, t }: { valueToBeRequired: string; t: TFunction }) => + yup + .mixed() + .when(SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SECRET_TYPE, ([secretType]) => + secretType === valueToBeRequired + ? yup.string().required(t('requiredField', { ns: 'validation' })) + : yup.mixed().optional(), + ); + +export const AddSecretFormSchema = (t: TFunction) => + yup.object({ + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SECRET_TYPE]: yup.string().required(), + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SECRET_NAME]: yup + .string() + .required(t('requiredField', { ns: 'validation' })), + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AWS_ACCESS_KEY_ID]: requiredField({ valueToBeRequired: PROVIDERS.AWS, t }), + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AWS_SECRET_ACCESS_KEY]: requiredField({ + valueToBeRequired: PROVIDERS.AWS, + t, + }), + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.GCP_SERVICE_ACCOUNT_CONTENTS]: requiredField({ + valueToBeRequired: PROVIDERS.GCP, + t, + }), + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AZURE_SUBSCRIPTION_ID]: requiredField({ + valueToBeRequired: PROVIDERS.AZURE, + t, + }), + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AZURE_CLIENT_ID]: requiredField({ valueToBeRequired: PROVIDERS.AZURE, t }), + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AZURE_SECRET]: requiredField({ valueToBeRequired: PROVIDERS.AZURE, t }), + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.AZURE_TENANT]: requiredField({ valueToBeRequired: PROVIDERS.AZURE, t }), + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.DO_API_TOKEN]: requiredField({ + valueToBeRequired: PROVIDERS.DIGITAL_OCEAN, + t, + }), + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.HCLOUD_API_TOKEN]: requiredField({ + valueToBeRequired: PROVIDERS.HETZNER, + t, + }), + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SSH_PRIVATE_KEY]: requiredField({ valueToBeRequired: 'ssh_key', t }), + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.USERNAME]: requiredField({ valueToBeRequired: 'password', t }), + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.PASSWORD]: requiredField({ valueToBeRequired: 'password', t }), + }); diff --git a/console/ui/src/features/add-secret/ui/index.tsx b/console/ui/src/features/add-secret/ui/index.tsx new file mode 100644 index 000000000..328c93d2e --- /dev/null +++ b/console/ui/src/features/add-secret/ui/index.tsx @@ -0,0 +1,150 @@ +import React, { useState } from 'react'; +import { Button, Card, CircularProgress, MenuItem, Modal, Select, Stack, TextField, Typography } from '@mui/material'; +import AddBoxOutlinedIcon from '@mui/icons-material/AddBoxOutlined'; +import { useTranslation } from 'react-i18next'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { AddSecretFormSchema } from '@features/add-secret/model/validation.ts'; +import { PROVIDERS } from '@shared/config/constants.ts'; +import { useAppSelector } from '@app/redux/store/hooks.ts'; +import { selectCurrentProject } from '@app/redux/slices/projectSlice/projectSelectors.ts'; +import { usePostSecretsMutation } from '@shared/api/api/secrets.ts'; +import { LoadingButton } from '@mui/lab'; +import { AUTHENTICATION_METHODS } from '@shared/model/constants.ts'; +import { toast } from 'react-toastify'; +import SecretFormBlock from '@entities/secret-form-block/ui'; + +import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; +import { SecretFormValues } from '@entities/secret-form-block/model/types.ts'; +import { getSecretBodyFromValues } from '@entities/secret-form-block/lib/functions.ts'; +import { handleRequestErrorCatch } from '@shared/lib/functions.ts'; + +const SettingsAddSecret: React.FC = () => { + const { t } = useTranslation(['settings', 'validation', 'toasts']); + const currentProject = useAppSelector(selectCurrentProject); + + const [isModalOpen, setIsModalOpen] = useState(false); + + const handleModalOpenState = (isOpen: boolean) => () => setIsModalOpen(isOpen); + + const [postSecretTrigger, postSecretTriggerState] = usePostSecretsMutation(); + + const methods = useForm({ + mode: 'all', + resolver: yupResolver(AddSecretFormSchema(t)), + defaultValues: { + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SECRET_TYPE]: '', + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SECRET_NAME]: '', + }, + }); + + const watchType = methods.watch(SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SECRET_TYPE); + + const onSubmit = async (values: SecretFormValues) => { + try { + if (currentProject) { + await postSecretTrigger({ + requestSecretCreate: { + project_id: Number(currentProject), + name: values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SECRET_NAME], + type: values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SECRET_TYPE], + value: getSecretBodyFromValues(values), + }, + }).unwrap(); + methods.reset(); + toast.success( + t('secretSuccessfullyCreated', { + ns: 'toasts', + secretName: values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SECRET_NAME], + }), + ); + setIsModalOpen(false); + } + } catch (e) { + handleRequestErrorCatch(e); + } + }; + + const { isValid, isSubmitting } = methods.formState; + + return ( + <> + + + +
+ + + + {t('addSecret', { ns: 'settings' })} + + + {t('secretType', { ns: 'settings' })} + ( + + )} + /> + + + {t('secretName', { ns: 'settings' })}* + ( + + )} + /> + + {watchType ? ( + + + } + loading={isSubmitting || postSecretTriggerState.isLoading}> + {t('addSecret')} + + + ) : null} + + +
+
+
+ + ); +}; + +export default SettingsAddSecret; diff --git a/console/ui/src/features/bradcrumbs/hooks/useBreadcrumbs.tsx b/console/ui/src/features/bradcrumbs/hooks/useBreadcrumbs.tsx new file mode 100644 index 000000000..b322dbf3f --- /dev/null +++ b/console/ui/src/features/bradcrumbs/hooks/useBreadcrumbs.tsx @@ -0,0 +1,19 @@ +import { useMatches } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +const useBreadcrumbs = (): { label: string; path: string }[] => { + const { t } = useTranslation(); + const matches = useMatches(); + + return matches + .filter((match: any) => Boolean(match?.handle?.breadcrumb)) + .map((match) => ({ + label: + typeof match.handle.breadcrumb.label === 'function' + ? match.handle.breadcrumb.label({ ...match.params }) + : t(match.handle.breadcrumb.label, { ns: match.handle.breadcrumb.ns }), + path: match.handle.breadcrumb?.path ?? match.pathname, + })); +}; + +export default useBreadcrumbs; diff --git a/console/ui/src/features/bradcrumbs/index.ts b/console/ui/src/features/bradcrumbs/index.ts new file mode 100644 index 000000000..3c2d1b2b0 --- /dev/null +++ b/console/ui/src/features/bradcrumbs/index.ts @@ -0,0 +1,3 @@ +import Breadcrumbs from '@/features/bradcrumbs/ui'; + +export default Breadcrumbs; diff --git a/console/ui/src/features/bradcrumbs/ui/index.tsx b/console/ui/src/features/bradcrumbs/ui/index.tsx new file mode 100644 index 000000000..8e5eb62ae --- /dev/null +++ b/console/ui/src/features/bradcrumbs/ui/index.tsx @@ -0,0 +1,35 @@ +import { FC } from 'react'; +import BreadcrumbsItem from '@entities/breadcumb-item'; +import useBreadcrumbs from '@/features/bradcrumbs/hooks/useBreadcrumbs.tsx'; +import { Breadcrumbs as MaterialBreadcrumbs, Icon, Typography } from '@mui/material'; +import RouterPaths from '@app/router/routerPathsConfig'; +import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'; +import { generateAbsoluteRouterPath } from '@shared/lib/functions.ts'; +import { Link } from 'react-router-dom'; + +const Breadcrumbs: FC = () => { + const breadcrumbs = useBreadcrumbs(); + + return ( + + + + + + + {breadcrumbs.map((breadcrumb, index) => + index === breadcrumbs.length - 1 ? ( + + {breadcrumb.label} + + ) : ( + + ), + )} + + ); +}; + +export default Breadcrumbs; diff --git a/console/ui/src/features/cluster-secret-modal/index.ts b/console/ui/src/features/cluster-secret-modal/index.ts new file mode 100644 index 000000000..1e2556575 --- /dev/null +++ b/console/ui/src/features/cluster-secret-modal/index.ts @@ -0,0 +1,3 @@ +import ClusterSecretModal from '@features/cluster-secret-modal/ui'; + +export default ClusterSecretModal; diff --git a/console/ui/src/features/cluster-secret-modal/lib/functions.ts b/console/ui/src/features/cluster-secret-modal/lib/functions.ts new file mode 100644 index 000000000..5e2864857 --- /dev/null +++ b/console/ui/src/features/cluster-secret-modal/lib/functions.ts @@ -0,0 +1,169 @@ +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: 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: 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), + ), +}); diff --git a/console/ui/src/features/cluster-secret-modal/model/constants.ts b/console/ui/src/features/cluster-secret-modal/model/constants.ts new file mode 100644 index 000000000..04ee1d8df --- /dev/null +++ b/console/ui/src/features/cluster-secret-modal/model/constants.ts @@ -0,0 +1,16 @@ +import { PROVIDERS } from '@shared/config/constants.ts'; + +import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; + +export const CLUSTER_SECRET_MODAL_FORM_FIELD_NAMES = Object.freeze({ + ...SECRET_MODAL_CONTENT_FORM_FIELD_NAMES, + IS_SAVE_TO_CONSOLE: 'isSaveToConsole', +}); + +export const PROVIDER_CODE_TO_ANSIBLE_USER_MAP = Object.freeze({ + [PROVIDERS.AWS]: 'ubuntu', + [PROVIDERS.GCP]: 'root', + [PROVIDERS.AZURE]: 'azureadmin', + [PROVIDERS.DIGITAL_OCEAN]: 'root', + [PROVIDERS.HETZNER]: 'root', +}); diff --git a/console/ui/src/features/cluster-secret-modal/model/types.ts b/console/ui/src/features/cluster-secret-modal/model/types.ts new file mode 100644 index 000000000..48f055a98 --- /dev/null +++ b/console/ui/src/features/cluster-secret-modal/model/types.ts @@ -0,0 +1,53 @@ +import { CLUSTER_SECRET_MODAL_FORM_FIELD_NAMES } from '@features/cluster-secret-modal/model/constants.ts'; +import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; +import { + DeploymentInfoCloudRegion, + DeploymentInstanceType, + 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'; + +export interface ClusterSecretModalProps { + isClusterFormSubmitting?: boolean; + isClusterFormDisabled?: boolean; +} + +export interface ClusterSecretModalFormValues extends SecretFormValues { + [CLUSTER_SECRET_MODAL_FORM_FIELD_NAMES.IS_SAVE_TO_CONSOLE]: boolean; +} + +interface ClusterCloudProviderFormValues { + [CLUSTER_FORM_FIELD_NAMES.REGION]?: string; + [CLUSTER_FORM_FIELD_NAMES.REGION_CONFIG]?: DeploymentInfoCloudRegion; + [CLUSTER_FORM_FIELD_NAMES.INSTANCE_TYPE]?: ['small', 'medium', 'large']; + [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; +} + +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; + [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 { + [CLUSTER_FORM_FIELD_NAMES.PROVIDER]: ResponseDeploymentInfo; + [CLUSTER_FORM_FIELD_NAMES.ENVIRONMENT_ID]: number; + [CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME]: string; + [CLUSTER_FORM_FIELD_NAMES.DESCRIPTION]: string; + [CLUSTER_FORM_FIELD_NAMES.POSTGRES_VERSION]: number; +} diff --git a/console/ui/src/features/cluster-secret-modal/model/validation.ts b/console/ui/src/features/cluster-secret-modal/model/validation.ts new file mode 100644 index 000000000..e69de29bb diff --git a/console/ui/src/features/cluster-secret-modal/ui/index.tsx b/console/ui/src/features/cluster-secret-modal/ui/index.tsx new file mode 100644 index 000000000..1bfeb6804 --- /dev/null +++ b/console/ui/src/features/cluster-secret-modal/ui/index.tsx @@ -0,0 +1,219 @@ +import { FC, useRef, useState } from 'react'; +import { + Box, + Button, + Card, + Checkbox, + CircularProgress, + FormControlLabel, + MenuItem, + Modal, + Stack, + TextField, +} from '@mui/material'; +import { Controller, FormProvider, useForm, useFormContext } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; +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 { LoadingButton } from '@mui/lab'; +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'; + +const ClusterSecretModal: FC = ({ isClusterFormDisabled = false }) => { + const { t } = useTranslation(['clusters', 'shared', '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 currentProject = useAppSelector(selectCurrentProject); + + const [isModalOpen, setIsModalOpen] = useState(false); + + const { watch, getValues } = useFormContext(); + + const watchProvider = watch(CLUSTER_FORM_FIELD_NAMES.PROVIDER); + + const secrets = useGetSecretsQuery({ type: watchProvider?.code, projectId: currentProject }); + + const [addSecretTrigger, addSecretTriggerState] = usePostSecretsMutation(); + const [addClusterTrigger, addClusterTriggerState] = usePostClustersMutation(); + + const methods = useForm(); + + const watchIsSaveToConsole = methods.watch(CLUSTER_SECRET_MODAL_FORM_FIELD_NAMES.IS_SAVE_TO_CONSOLE); + + const handleModalOpenState = (isOpen: boolean) => () => setIsModalOpen(isOpen); + + const cancelHandler = () => navigate(generateAbsoluteRouterPath(RouterPaths.clusters.absolutePath)); + + const onSubmit = async (values: ClusterSecretModalFormValues) => { + const clusterFormValues = getValues(); + try { + if (values[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], + value: getSecretBodyFromValues({ + ...values, + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SECRET_TYPE]: clusterFormValues.provider.code, + }), + }, + }).unwrap(); + toast.success( + t('secretSuccessfullyCreated', { + ns: 'toasts', + secretName: values[SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SECRET_NAME], + }), + ); + } + if (!secrets.data?.data?.length && !createSecretResultRef?.current?.id) { + await addClusterTrigger({ + requestClusterCreate: mapFormValuesToRequestFields({ + values: clusterFormValues, + envs: values, + projectId: Number(currentProject), + }), + }).unwrap(); + } else { + await addClusterTrigger({ + requestClusterCreate: mapFormValuesToRequestFields({ + values: clusterFormValues, + secretId: createSecretResultRef.current?.id ?? values[CLUSTER_FORM_FIELD_NAMES.SECRET_ID], + projectId: Number(currentProject), + }), + }).unwrap(); + } + toast.success( + t('clusterSuccessfullyCreated', { + ns: 'toasts', + clusterName: clusterFormValues[CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME], + }), + ); + navigate(generateAbsoluteRouterPath(RouterPaths.clusters.absolutePath)); + } catch (e) { + handleRequestErrorCatch(e); + } finally { + setIsModalOpen(false); + } + }; + + const { isValid, isDirty, isSubmitting } = methods.formState; + + return ( + + + {t('createCluster', { ns: 'clusters' })} + + + + +
+ + + {secrets.data?.data?.length > 1 ? ( + ( + + {secrets.data.data.map((secret) => ( + + {secret?.name} + + ))} + + )} + /> + ) : ( + <> + + {watchIsSaveToConsole ? ( + ( + + )} + /> + ) : null} + ( + } + checked={value} + onChange={onChange} + label={t('saveToConsole', { ns: 'clusters' })} + /> + )} + /> + + )} + } + fullWidth={false}> + {t('createCluster', { ns: 'clusters' })} + + + +
+
+
+
+ +
+ ); +}; + +export default ClusterSecretModal; diff --git a/console/ui/src/features/clusters-overview-table-row-actions/index.ts b/console/ui/src/features/clusters-overview-table-row-actions/index.ts new file mode 100644 index 000000000..11ec5d22e --- /dev/null +++ b/console/ui/src/features/clusters-overview-table-row-actions/index.ts @@ -0,0 +1,3 @@ +import ClustersOverviewTableRowActions from '@features/clusters-overview-table-row-actions/ui'; + +export default ClustersOverviewTableRowActions; diff --git a/console/ui/src/features/clusters-overview-table-row-actions/ui/index.tsx b/console/ui/src/features/clusters-overview-table-row-actions/ui/index.tsx new file mode 100644 index 000000000..624ec646f --- /dev/null +++ b/console/ui/src/features/clusters-overview-table-row-actions/ui/index.tsx @@ -0,0 +1,47 @@ +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { handleRequestErrorCatch } from '@shared/lib/functions.ts'; +import { ListItemIcon, MenuItem } from '@mui/material'; +import { TableRowActionsProps } from '@shared/model/types.ts'; +import { toast } from 'react-toastify'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import { useDeleteServersByIdMutation } from '@shared/api/api/other.ts'; +import { CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES } from '@widgets/cluster-overview-table/model/constants.ts'; +import { useLazyGetClustersByIdQuery } from '@shared/api/api/clusters.ts'; +import { useParams } from 'react-router-dom'; + +const ClustersOverviewTableRowActions: FC = ({ closeMenu, row }) => { + const { t } = useTranslation(['shared', 'toasts']); + const { clusterId } = useParams(); + + const [removeServerTrigger] = useDeleteServersByIdMutation(); + const [getClusterTrigger] = useLazyGetClustersByIdQuery(); + + const handleButtonClick = async () => { + try { + await removeServerTrigger({ id: row.original[CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.ID] }).unwrap(); + toast.success( + t('serverSuccessfullyRemoved', { + ns: 'toasts', + serverName: row.original[CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.NAME], + }), + ); + await getClusterTrigger({ id: clusterId }); + } catch (e) { + handleRequestErrorCatch(e); + } finally { + closeMenu(); + } + }; + + return [ + + + + + {t('removeFromList', { ns: 'shared' })} + , + ]; +}; + +export default ClustersOverviewTableRowActions; diff --git a/console/ui/src/features/clusters-table-buttons/index.ts b/console/ui/src/features/clusters-table-buttons/index.ts new file mode 100644 index 000000000..d8302b69b --- /dev/null +++ b/console/ui/src/features/clusters-table-buttons/index.ts @@ -0,0 +1,3 @@ +import ClustersTableButtons from '@features/clusters-table-buttons/ui'; + +export default ClustersTableButtons; diff --git a/console/ui/src/features/clusters-table-buttons/model/types.ts b/console/ui/src/features/clusters-table-buttons/model/types.ts new file mode 100644 index 000000000..ed84d60ba --- /dev/null +++ b/console/ui/src/features/clusters-table-buttons/model/types.ts @@ -0,0 +1,3 @@ +export interface ClustersTableButtonsProps { + refetch: () => void; +} diff --git a/console/ui/src/features/clusters-table-buttons/ui/index.tsx b/console/ui/src/features/clusters-table-buttons/ui/index.tsx new file mode 100644 index 000000000..3d12a84ac --- /dev/null +++ b/console/ui/src/features/clusters-table-buttons/ui/index.tsx @@ -0,0 +1,35 @@ +import { useTranslation } from 'react-i18next'; +import { Button, Stack } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import { generateAbsoluteRouterPath } from '@shared/lib/functions.ts'; +import RouterPaths from '@app/router/routerPathsConfig'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import AddIcon from '@mui/icons-material/Add'; +import { ClustersTableButtonsProps } from '@features/clusters-table-buttons/model/types.ts'; +import { FC } from 'react'; + +const ClustersTableButtons: FC = ({ refetch }) => { + const { t } = useTranslation(['clusters, shared']); + const navigate = useNavigate(); + + const handleRefresh = () => { + refetch(); + }; + + const handleCreateCluster = () => { + navigate(generateAbsoluteRouterPath(RouterPaths.clusters.add.absolutePath)); + }; + + return ( + + + + + ); +}; + +export default ClustersTableButtons; diff --git a/console/ui/src/features/clusters-table-row-actions/index.ts b/console/ui/src/features/clusters-table-row-actions/index.ts new file mode 100644 index 000000000..84a49ed2d --- /dev/null +++ b/console/ui/src/features/clusters-table-row-actions/index.ts @@ -0,0 +1,3 @@ +import ClustersTableRowActions from '@features/clusters-table-row-actions/ui'; + +export default ClustersTableRowActions; diff --git a/console/ui/src/features/clusters-table-row-actions/model/types.ts b/console/ui/src/features/clusters-table-row-actions/model/types.ts new file mode 100644 index 000000000..84135902e --- /dev/null +++ b/console/ui/src/features/clusters-table-row-actions/model/types.ts @@ -0,0 +1,5 @@ +export interface ClustersTableRemoveButtonProps { + clusterId: number; + clusterName: string; + closeMenu: () => void; +} diff --git a/console/ui/src/features/clusters-table-row-actions/ui/ClusterTableRemoveButton.tsx b/console/ui/src/features/clusters-table-row-actions/ui/ClusterTableRemoveButton.tsx new file mode 100644 index 000000000..a168dc9be --- /dev/null +++ b/console/ui/src/features/clusters-table-row-actions/ui/ClusterTableRemoveButton.tsx @@ -0,0 +1,71 @@ +import { FC, useState } from 'react'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Stack, + Typography, +} from '@mui/material'; +import { LoadingButton } from '@mui/lab'; +import { useTranslation } from 'react-i18next'; +import { ClustersTableRemoveButtonProps } from '@features/clusters-table-row-actions/model/types.ts'; +import { useDeleteClustersByIdMutation } from '@shared/api/api/clusters.ts'; +import { toast } from 'react-toastify'; +import { handleRequestErrorCatch } from '@shared/lib/functions.ts'; + +const ClustersTableRemoveButton: FC = ({ clusterId, clusterName, closeMenu }) => { + const { t } = useTranslation(['clusters', 'shared']); + + const [isModalOpen, setIsModalOpen] = useState(false); + + const [removeClusterTrigger, removeClusterTriggerState] = useDeleteClustersByIdMutation(); + + const handleModalOpenState = (state: boolean) => () => { + setIsModalOpen(state); + if (!state) closeMenu(); + }; + + const handleButtonClick = async () => { + try { + await removeClusterTrigger({ id: clusterId }); + closeMenu(); + toast.success(t('clusterSuccessfullyRemoved', { ns: 'toasts', clusterName })); + } catch (e) { + handleRequestErrorCatch(e); + } + }; + + return ( + <> + + + {t('deleteClusterModalHeader', { ns: 'clusters', clusterName })} + + {t('deleteClusterModalBody', { ns: 'clusters', clusterName })} + + + + } + loading={removeClusterTriggerState.isLoading}> + {t('delete', { ns: 'shared' })} + + + + + ); +}; + +export default ClustersTableRemoveButton; diff --git a/console/ui/src/features/clusters-table-row-actions/ui/index.tsx b/console/ui/src/features/clusters-table-row-actions/ui/index.tsx new file mode 100644 index 000000000..4fcfda388 --- /dev/null +++ b/console/ui/src/features/clusters-table-row-actions/ui/index.tsx @@ -0,0 +1,14 @@ +import { FC } from 'react'; +import { TableRowActionsProps } from '@shared/model/types.ts'; +import ClustersTableRemoveButton from '@features/clusters-table-row-actions/ui/ClusterTableRemoveButton.tsx'; + +const ClustersTableRowActions: FC = ({ closeMenu, row }) => [ + , +]; + +export default ClustersTableRowActions; diff --git a/console/ui/src/features/environments-table-row-actions/ui/index.tsx b/console/ui/src/features/environments-table-row-actions/ui/index.tsx new file mode 100644 index 000000000..1e7bbad94 --- /dev/null +++ b/console/ui/src/features/environments-table-row-actions/ui/index.tsx @@ -0,0 +1,42 @@ +import { FC } from 'react'; +import { TableRowActionsProps } from '@shared/model/types.ts'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { handleRequestErrorCatch } from '@shared/lib/functions.ts'; +import { ListItemIcon, MenuItem } from '@mui/material'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import { useDeleteEnvironmentsByIdMutation } from '@shared/api/api/environments.ts'; +import { ENVIRONMENTS_TABLE_COLUMN_NAMES } from '@widgets/environments-table/model/constants.ts'; + +const EnvironmentsTableRowActions: FC = ({ closeMenu, row }) => { + const { t } = useTranslation(['shared', 'toasts']); + + const [removeEnvironmentTrigger] = useDeleteEnvironmentsByIdMutation(); + + const handleButtonClick = async () => { + try { + await removeEnvironmentTrigger({ id: row.original[ENVIRONMENTS_TABLE_COLUMN_NAMES.ID] }).unwrap(); + toast.success( + t('environmentSuccessfullyRemoved', { + ns: 'toasts', + environmentName: row.original[ENVIRONMENTS_TABLE_COLUMN_NAMES.NAME], + }), + ); + } catch (e) { + handleRequestErrorCatch(e); + } finally { + closeMenu(); + } + }; + + return [ + + + + + {t('delete')} + , + ]; +}; + +export default EnvironmentsTableRowActions; diff --git a/console/ui/src/features/logout-button/index.ts b/console/ui/src/features/logout-button/index.ts new file mode 100644 index 000000000..5940e264f --- /dev/null +++ b/console/ui/src/features/logout-button/index.ts @@ -0,0 +1,3 @@ +import LogoutButton from '@features/logout-button/ui'; + +export default LogoutButton; diff --git a/console/ui/src/features/logout-button/ui/index.tsx b/console/ui/src/features/logout-button/ui/index.tsx new file mode 100644 index 000000000..1c6d206d7 --- /dev/null +++ b/console/ui/src/features/logout-button/ui/index.tsx @@ -0,0 +1,23 @@ +import { FC } from 'react'; +import Logout from '@shared/assets/logoutIcon.svg?react'; +import { Icon } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import RouterPaths from '@app/router/routerPathsConfig'; +import { generateAbsoluteRouterPath } from '@shared/lib/functions.ts'; + +const LogoutButton: FC = () => { + const navigate = useNavigate(); + + const handleLogout = () => { + localStorage.removeItem('token'); + navigate(generateAbsoluteRouterPath(RouterPaths.login.absolutePath)); + }; + + return ( + + + + ); +}; + +export default LogoutButton; diff --git a/console/ui/src/features/operations-table-buttons/index.ts b/console/ui/src/features/operations-table-buttons/index.ts new file mode 100644 index 000000000..1e48e3d8c --- /dev/null +++ b/console/ui/src/features/operations-table-buttons/index.ts @@ -0,0 +1,3 @@ +import OperationsTableButtons from '@features/operations-table-buttons/ui'; + +export default OperationsTableButtons; diff --git a/console/ui/src/features/operations-table-buttons/lib/functions.ts b/console/ui/src/features/operations-table-buttons/lib/functions.ts new file mode 100644 index 000000000..d342c7c15 --- /dev/null +++ b/console/ui/src/features/operations-table-buttons/lib/functions.ts @@ -0,0 +1,61 @@ +import { startOfDay } from 'date-fns/startOfDay'; +import { subDays } from 'date-fns/subDays'; +import { subMonths } from 'date-fns/subMonths'; +import { subYears } from 'date-fns/subYears'; +import { TFunction } from 'i18next'; +import { DATE_RANGE_VALUES } from '@features/operations-table-buttons/model/constants.ts'; + +export const formatOperationsDate = (date: Date) => startOfDay(date).toISOString(); + +export const getOperationsTimeNameValue = (name: keyof DATE_RANGE_VALUES) => { + let value = ''; + + switch (name) { + case DATE_RANGE_VALUES.LAST_DAY: + value = formatOperationsDate(subDays(new Date(), 1)); + break; + case DATE_RANGE_VALUES.LAST_WEEK: + value = formatOperationsDate(subDays(new Date(), 7)); + break; + case DATE_RANGE_VALUES.LAST_MONTH: + value = formatOperationsDate(subMonths(new Date(), 1)); + break; + case DATE_RANGE_VALUES.LAST_THREE_MONTHS: + value = formatOperationsDate(subMonths(new Date(), 3)); + break; + case DATE_RANGE_VALUES.LAST_SIX_MONTHS: + value = formatOperationsDate(subMonths(new Date(), 6)); + break; + case DATE_RANGE_VALUES.LAST_YEAR: + value = formatOperationsDate(subYears(new Date(), 1)); + break; + } + return { name, value }; +}; + +export const getOperationsDateRangeVariants = (t: TFunction) => [ + { + label: t('lastDay', { ns: 'operations' }), + value: DATE_RANGE_VALUES.LAST_DAY, + }, + { + label: t('lastWeek', { ns: 'operations' }), + value: DATE_RANGE_VALUES.LAST_WEEK, + }, + { + label: t('lastMonth', { ns: 'operations' }), + value: DATE_RANGE_VALUES.LAST_MONTH, + }, + { + label: t('lastThreeMonths', { ns: 'operations' }), + value: DATE_RANGE_VALUES.LAST_THREE_MONTHS, + }, + { + label: t('lastSixMonths', { ns: 'operations' }), + value: DATE_RANGE_VALUES.LAST_SIX_MONTHS, + }, + { + label: t('lastYear', { ns: 'operations' }), + value: DATE_RANGE_VALUES.LAST_YEAR, + }, +]; diff --git a/console/ui/src/features/operations-table-buttons/model/constants.ts b/console/ui/src/features/operations-table-buttons/model/constants.ts new file mode 100644 index 000000000..091945239 --- /dev/null +++ b/console/ui/src/features/operations-table-buttons/model/constants.ts @@ -0,0 +1,8 @@ +export const DATE_RANGE_VALUES = Object.freeze({ + LAST_DAY: 'lastDay', + LAST_WEEK: 'lastWeek', + LAST_MONTH: 'lastMonth', + LAST_THREE_MONTHS: 'lastThreeMonths', + LAST_SIX_MONTHS: 'lastSixMonths', + LAST_YEAR: 'lastYear', +}); diff --git a/console/ui/src/features/operations-table-buttons/model/types.ts b/console/ui/src/features/operations-table-buttons/model/types.ts new file mode 100644 index 000000000..c29b36eaf --- /dev/null +++ b/console/ui/src/features/operations-table-buttons/model/types.ts @@ -0,0 +1,5 @@ +export interface OperationsTableButtonsProps { + refetch: () => void; + startDate: Date; + setStartDate: (date: Date) => void; +} diff --git a/console/ui/src/features/operations-table-buttons/ui/index.tsx b/console/ui/src/features/operations-table-buttons/ui/index.tsx new file mode 100644 index 000000000..06e55fe71 --- /dev/null +++ b/console/ui/src/features/operations-table-buttons/ui/index.tsx @@ -0,0 +1,53 @@ +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, InputAdornment, MenuItem, Stack, TextField } from '@mui/material'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import { OperationsTableButtonsProps } from '@features/operations-table-buttons/model/types.ts'; +import CalendarClockIcon from '@shared/assets/calendarClockICon.svg?react'; +import { + getOperationsDateRangeVariants, + getOperationsTimeNameValue, +} from '@features/operations-table-buttons/lib/functions.ts'; + +const OperationsTableButtons: FC = ({ refetch, startDate, setStartDate }) => { + const { t } = useTranslation('operations'); + + const rangeOptions = getOperationsDateRangeVariants(t); + + const handleChange = (e) => { + setStartDate(getOperationsTimeNameValue(e.target.value)); + }; + + const handleRefresh = () => { + refetch(); + }; + + return ( + + + + + ), + }}> + {rangeOptions.map((option) => ( + + {option.label} + + ))} + + + + ); +}; + +export default OperationsTableButtons; diff --git a/console/ui/src/features/operations-table-row-actions/index.ts b/console/ui/src/features/operations-table-row-actions/index.ts new file mode 100644 index 000000000..cbce8e88f --- /dev/null +++ b/console/ui/src/features/operations-table-row-actions/index.ts @@ -0,0 +1,3 @@ +import OperationsTableRowActions from '@features/operations-table-row-actions/ui'; + +export default OperationsTableRowActions; diff --git a/console/ui/src/features/operations-table-row-actions/ui/index.tsx b/console/ui/src/features/operations-table-row-actions/ui/index.tsx new file mode 100644 index 000000000..0fdca6667 --- /dev/null +++ b/console/ui/src/features/operations-table-row-actions/ui/index.tsx @@ -0,0 +1,25 @@ +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { MenuItem } from '@mui/material'; +import { TableRowActionsProps } from '@shared/model/types.ts'; +import { useNavigate } from 'react-router-dom'; +import RouterPaths from '@app/router/routerPathsConfig'; +import { generateAbsoluteRouterPath } from '@shared/lib/functions.ts'; + +const OperationsTableRowActions: FC = ({ closeMenu, row }) => { + const { t } = useTranslation('operations'); + const navigate = useNavigate(); + + const handleButtonClick = () => { + navigate(generateAbsoluteRouterPath(RouterPaths.operations.log.absolutePath, { operationId: row.original.id })); + closeMenu(); + }; + + return [ + + {t('showDetails')} + , + ]; +}; + +export default OperationsTableRowActions; diff --git a/console/ui/src/features/pojects-table-row-actions/index.ts b/console/ui/src/features/pojects-table-row-actions/index.ts new file mode 100644 index 000000000..8c4e9a64d --- /dev/null +++ b/console/ui/src/features/pojects-table-row-actions/index.ts @@ -0,0 +1,3 @@ +import ProjectsTableRowActions from '@features/pojects-table-row-actions/ui'; + +export default ProjectsTableRowActions; diff --git a/console/ui/src/features/pojects-table-row-actions/ui/index.tsx b/console/ui/src/features/pojects-table-row-actions/ui/index.tsx new file mode 100644 index 000000000..6cffc382d --- /dev/null +++ b/console/ui/src/features/pojects-table-row-actions/ui/index.tsx @@ -0,0 +1,48 @@ +import { FC } from 'react'; +import { ListItemIcon, MenuItem } from '@mui/material'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import { useTranslation } from 'react-i18next'; +import { useDeleteProjectsByIdMutation } from '@shared/api/api/projects.ts'; +import { toast } from 'react-toastify'; +import { handleRequestErrorCatch } from '@shared/lib/functions.ts'; +import { TableRowActionsProps } from '@shared/model/types.ts'; +import { PROJECTS_TABLE_COLUMN_NAMES } from '@widgets/projects-table/model/constants.ts'; +import { useAppSelector } from '@app/redux/store/hooks.ts'; +import { selectCurrentProject } from '@app/redux/slices/projectSlice/projectSelectors.ts'; + +const ProjectsTableRowActions: FC = ({ closeMenu, row }) => { + const { t } = useTranslation(['shared', 'toasts']); + + const currentProject = useAppSelector(selectCurrentProject); + + const [removeProjectTrigger] = useDeleteProjectsByIdMutation(); + + const handleButtonClick = async () => { + try { + if (Number(currentProject) === row.original[PROJECTS_TABLE_COLUMN_NAMES.ID]) + throw t('cannotRemoveActiveProject', { ns: 'toasts' }); + await removeProjectTrigger({ id: row.original[PROJECTS_TABLE_COLUMN_NAMES.ID] }).unwrap(); + toast.success( + t('projectSuccessfullyRemoved', { + ns: 'toasts', + projectName: row.original[PROJECTS_TABLE_COLUMN_NAMES.NAME], + }), + ); + } catch (e) { + handleRequestErrorCatch(e); + } finally { + closeMenu(); + } + }; + + return [ + + + + + {t('delete')} + , + ]; +}; + +export default ProjectsTableRowActions; diff --git a/console/ui/src/features/settings-table-buttons/index.ts b/console/ui/src/features/settings-table-buttons/index.ts new file mode 100644 index 000000000..aadca66d8 --- /dev/null +++ b/console/ui/src/features/settings-table-buttons/index.ts @@ -0,0 +1,3 @@ +import SettingsTableButtons from '@features/settings-table-buttons/ui'; + +export default SettingsTableButtons; diff --git a/console/ui/src/features/settings-table-buttons/lib/functions.ts b/console/ui/src/features/settings-table-buttons/lib/functions.ts new file mode 100644 index 000000000..906dd06ef --- /dev/null +++ b/console/ui/src/features/settings-table-buttons/lib/functions.ts @@ -0,0 +1,3 @@ +export const handleDelete = () => {}; +export const handleEdit = () => {}; +export const handleAddSecret = () => {}; diff --git a/console/ui/src/features/settings-table-buttons/ui/index.tsx b/console/ui/src/features/settings-table-buttons/ui/index.tsx new file mode 100644 index 000000000..f451c805c --- /dev/null +++ b/console/ui/src/features/settings-table-buttons/ui/index.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Stack } from '@mui/material'; +import SettingsAddSecret from '@features/add-secret'; + +const SettingsTableButtons: React.FC = () => { + const { t } = useTranslation(['shared', 'settings']); + + return ( + + + + ); +}; + +export default SettingsTableButtons; diff --git a/console/ui/src/features/settings-table-row-actions/index.ts b/console/ui/src/features/settings-table-row-actions/index.ts new file mode 100644 index 000000000..3a966bb65 --- /dev/null +++ b/console/ui/src/features/settings-table-row-actions/index.ts @@ -0,0 +1,3 @@ +import SettingsTableRowActions from '@features/settings-table-row-actions/ui'; + +export default SettingsTableRowActions; diff --git a/console/ui/src/features/settings-table-row-actions/model/constants.ts b/console/ui/src/features/settings-table-row-actions/model/constants.ts new file mode 100644 index 000000000..ceb434bcc --- /dev/null +++ b/console/ui/src/features/settings-table-row-actions/model/constants.ts @@ -0,0 +1 @@ +export const SECRET_TOAST_DISPLAY_CLUSTERS_LIMIT = 10; diff --git a/console/ui/src/features/settings-table-row-actions/ui/index.tsx b/console/ui/src/features/settings-table-row-actions/ui/index.tsx new file mode 100644 index 000000000..dcc4a1423 --- /dev/null +++ b/console/ui/src/features/settings-table-row-actions/ui/index.tsx @@ -0,0 +1,57 @@ +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ListItemIcon, MenuItem } from '@mui/material'; +import { useDeleteSecretsByIdMutation } from '@shared/api/api/secrets.ts'; +import { TableRowActionsProps } from '@shared/model/types.ts'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import { toast } from 'react-toastify'; +import { handleRequestErrorCatch } from '@shared/lib/functions.ts'; +import { SECRETS_TABLE_COLUMN_NAMES } from '@widgets/secrets-table/model/constants.ts'; +import { SECRET_TOAST_DISPLAY_CLUSTERS_LIMIT } from '@features/settings-table-row-actions/model/constants.ts'; + +const SettingsTableRowActions: FC = ({ closeMenu, row }) => { + const { t } = useTranslation(['shared', 'toasts']); + + const [removeSecretTrigger] = useDeleteSecretsByIdMutation(); + + const handleButtonClick = async () => { + try { + if (row.original[SECRETS_TABLE_COLUMN_NAMES.USED].toString() === 'true') { + const usingClusterList = row.original[SECRETS_TABLE_COLUMN_NAMES.USED_BY]?.split(', '); + toast.warning( + t('secretsSecretIsUsed', { + ns: 'toasts', + count: usingClusterList?.length, + clusterNames: + usingClusterList?.length > SECRET_TOAST_DISPLAY_CLUSTERS_LIMIT + ? `${[...usingClusterList.slice(0, SECRET_TOAST_DISPLAY_CLUSTERS_LIMIT), '...'].join(', ')}` + : row.original[SECRETS_TABLE_COLUMN_NAMES.USED_BY], + }), + ); + } else { + await removeSecretTrigger({ id: row.original[SECRETS_TABLE_COLUMN_NAMES.ID] }).unwrap(); + toast.success( + t('secretSuccessfullyRemoved', { + ns: 'toasts', + secretName: row.original[SECRETS_TABLE_COLUMN_NAMES.NAME], + }), + ); + } + } catch (e) { + handleRequestErrorCatch(e); + } finally { + closeMenu(); + } + }; + + return [ + + + + + {t('delete')} + , + ]; +}; + +export default SettingsTableRowActions; diff --git a/console/ui/src/pages/404/index.ts b/console/ui/src/pages/404/index.ts new file mode 100644 index 000000000..29e8d7e6a --- /dev/null +++ b/console/ui/src/pages/404/index.ts @@ -0,0 +1,3 @@ +import Page404 from '@pages/404/ui'; + +export default Page404; diff --git a/console/ui/src/pages/404/ui/illustration.tsx b/console/ui/src/pages/404/ui/illustration.tsx new file mode 100644 index 000000000..00fc35061 --- /dev/null +++ b/console/ui/src/pages/404/ui/illustration.tsx @@ -0,0 +1,14 @@ +import { ComponentPropsWithoutRef } from 'react'; + +const Illustration = (props: ComponentPropsWithoutRef<'svg'>) => { + return ( + + + + ); +}; + +export default Illustration; diff --git a/console/ui/src/pages/404/ui/index.tsx b/console/ui/src/pages/404/ui/index.tsx new file mode 100644 index 000000000..d11064428 --- /dev/null +++ b/console/ui/src/pages/404/ui/index.tsx @@ -0,0 +1,39 @@ +import { FC } from 'react'; +import Illustration from '@pages/404/ui/illustration.tsx'; +import { useTranslation } from 'react-i18next'; +import RouterPaths from '@app/router/routerPathsConfig'; +import { generateAbsoluteRouterPath } from '@shared/lib/functions.ts'; +import { useNavigate } from 'react-router-dom'; +import { Box, Button, Container, Typography } from '@mui/material'; +import theme from '@shared/theme/theme.ts'; +import { grey } from '@mui/material/colors'; + +const Page404: FC = () => { + const { t } = useTranslation('shared'); + const navigate = useNavigate(); + + const handleReturnButton = () => navigate(generateAbsoluteRouterPath(RouterPaths.clusters.absolutePath)); + + return ( + + + + + + {t('404Title')} + + + {t('404Text')} + + + + + + + + ); +}; + +export default Page404; diff --git a/console/ui/src/pages/add-cluster/index.ts b/console/ui/src/pages/add-cluster/index.ts new file mode 100644 index 000000000..b4ea872b3 --- /dev/null +++ b/console/ui/src/pages/add-cluster/index.ts @@ -0,0 +1,3 @@ +import AddCluster from '@pages/add-cluster/ui'; + +export default AddCluster; diff --git a/console/ui/src/pages/add-cluster/ui/index.tsx b/console/ui/src/pages/add-cluster/ui/index.tsx new file mode 100644 index 000000000..102b3724a --- /dev/null +++ b/console/ui/src/pages/add-cluster/ui/index.tsx @@ -0,0 +1,8 @@ +import { FC } from 'react'; +import ClusterForm from '@widgets/cluster-form'; + +const AddCluster: FC = () => { + return ; +}; + +export default AddCluster; diff --git a/console/ui/src/pages/clusters/index.ts b/console/ui/src/pages/clusters/index.ts new file mode 100644 index 000000000..f3e8d8643 --- /dev/null +++ b/console/ui/src/pages/clusters/index.ts @@ -0,0 +1,3 @@ +import Clusters from '@pages/clusters/ui'; + +export default Clusters; diff --git a/console/ui/src/pages/clusters/ui/index.tsx b/console/ui/src/pages/clusters/ui/index.tsx new file mode 100644 index 000000000..06daa706b --- /dev/null +++ b/console/ui/src/pages/clusters/ui/index.tsx @@ -0,0 +1,13 @@ +import { FC } from 'react'; +import { Box } from '@mui/material'; +import ClustersTable from '@widgets/clusters-table'; + +const Clusters: FC = () => { + return ( + + + + ); +}; + +export default Clusters; diff --git a/console/ui/src/pages/login/index.ts b/console/ui/src/pages/login/index.ts new file mode 100644 index 000000000..6895e2a10 --- /dev/null +++ b/console/ui/src/pages/login/index.ts @@ -0,0 +1,3 @@ +import Login from '@pages/login/ui'; + +export default Login; diff --git a/console/ui/src/pages/login/model/constants.ts b/console/ui/src/pages/login/model/constants.ts new file mode 100644 index 000000000..cfeaebc7c --- /dev/null +++ b/console/ui/src/pages/login/model/constants.ts @@ -0,0 +1,3 @@ +export const LOGIN_FORM_FIELD_NAMES = Object.freeze({ + TOKEN: 'token', +}); diff --git a/console/ui/src/pages/login/model/types.ts b/console/ui/src/pages/login/model/types.ts new file mode 100644 index 000000000..50729a4d5 --- /dev/null +++ b/console/ui/src/pages/login/model/types.ts @@ -0,0 +1,5 @@ +import { LOGIN_FORM_FIELD_NAMES } from '@pages/login/model/constants.ts'; + +export interface LoginFormValues { + [LOGIN_FORM_FIELD_NAMES.TOKEN]: string; +} diff --git a/console/ui/src/pages/login/ui/index.tsx b/console/ui/src/pages/login/ui/index.tsx new file mode 100644 index 000000000..a39661378 --- /dev/null +++ b/console/ui/src/pages/login/ui/index.tsx @@ -0,0 +1,75 @@ +import { FC } from 'react'; +import { Box, Button, Link, Paper, Stack, TextField, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import RouterPaths from '@app/router/routerPathsConfig'; +import { generateAbsoluteRouterPath } from '@shared/lib/functions.ts'; +import { Controller, useForm } from 'react-hook-form'; +import { LoginFormValues } from '@pages/login/model/types.ts'; +import { LOGIN_FORM_FIELD_NAMES } from '@pages/login/model/constants.ts'; +import { version } from '../../../../package.json'; +import Logo from '@shared/assets/PGCLogo.svg?react'; + +const Login: FC = () => { + const { t } = useTranslation('shared'); + const navigate = useNavigate(); + + const { handleSubmit, control } = useForm(); + + const onSubmit = (values: LoginFormValues) => { + localStorage.setItem('token', values[LOGIN_FORM_FIELD_NAMES.TOKEN]); + navigate(generateAbsoluteRouterPath(RouterPaths.clusters.absolutePath)); + }; + + return ( + + +
+ + + PostgreSQL Cluster Console + ( + + )} + /> + + + v.{version} + + +
+ + + Powered by  + + GS Labs + + + +
+
+ ); +}; + +export default Login; diff --git a/console/ui/src/pages/operation-log/index.ts b/console/ui/src/pages/operation-log/index.ts new file mode 100644 index 000000000..3922eaa48 --- /dev/null +++ b/console/ui/src/pages/operation-log/index.ts @@ -0,0 +1,3 @@ +import OperationLog from '@pages/operation-log/ui'; + +export default OperationLog; diff --git a/console/ui/src/pages/operation-log/ui/index.tsx b/console/ui/src/pages/operation-log/ui/index.tsx new file mode 100644 index 000000000..965f928ee --- /dev/null +++ b/console/ui/src/pages/operation-log/ui/index.tsx @@ -0,0 +1,39 @@ +import { FC, useEffect, useState } from 'react'; +import { Box } from '@mui/material'; +import { useGetOperationsByIdLogQuery } from '@shared/api/api/operations.ts'; +import { useParams } from 'react-router-dom'; +import { LazyLog } from 'react-lazylog'; +import { useQueryPolling } from '@shared/lib/hooks.tsx'; +import { OPERATION_LOGS_POLLING_INTERVAL } from '@shared/config/constants.ts'; + +const OperationLog: FC = () => { + const { operationId } = useParams(); + const [isStopRequest, setIsStopRequest] = useState(false); + + const log = useQueryPolling( + () => useGetOperationsByIdLogQuery({ id: operationId }), + OPERATION_LOGS_POLLING_INTERVAL, + { stop: isStopRequest }, + ); + + useEffect(() => { + setIsStopRequest(!!log.data?.isComplete); + }, [log.data?.isComplete]); + + return ( + + + + ); +}; + +export default OperationLog; diff --git a/console/ui/src/pages/operations/index.ts b/console/ui/src/pages/operations/index.ts new file mode 100644 index 000000000..7f7303f70 --- /dev/null +++ b/console/ui/src/pages/operations/index.ts @@ -0,0 +1,3 @@ +import Operations from '@pages/operations/ui'; + +export default Operations; diff --git a/console/ui/src/pages/operations/ui/index.tsx b/console/ui/src/pages/operations/ui/index.tsx new file mode 100644 index 000000000..20a608281 --- /dev/null +++ b/console/ui/src/pages/operations/ui/index.tsx @@ -0,0 +1,13 @@ +import { FC } from 'react'; +import OperationsTable from '@widgets/operations-table'; +import { Box } from '@mui/material'; + +const Operations: FC = () => { + return ( + + + + ); +}; + +export default Operations; diff --git a/console/ui/src/pages/overview-cluster/index.ts b/console/ui/src/pages/overview-cluster/index.ts new file mode 100644 index 000000000..00ec7425c --- /dev/null +++ b/console/ui/src/pages/overview-cluster/index.ts @@ -0,0 +1,3 @@ +import OverviewCluster from '@pages/overview-cluster/ui'; + +export default OverviewCluster; diff --git a/console/ui/src/pages/overview-cluster/ui/index.tsx b/console/ui/src/pages/overview-cluster/ui/index.tsx new file mode 100644 index 000000000..aa49b2669 --- /dev/null +++ b/console/ui/src/pages/overview-cluster/ui/index.tsx @@ -0,0 +1,49 @@ +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +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 { useQueryPolling } from '@shared/lib/hooks.tsx'; +import { CLUSTER_OVERVIEW_POLLING_INTERVAL } from '@shared/config/constants.ts'; +import Spinner from '@shared/ui/spinner'; + +const OverviewCluster: FC = () => { + const { t } = useTranslation('clusters'); + const { clusterId } = useParams(); + + const cluster = useQueryPolling(() => useGetClustersByIdQuery({ id: clusterId }), CLUSTER_OVERVIEW_POLLING_INTERVAL); + + const connectionInfo = cluster.data?.connection_info; + + return cluster.isLoading ? ( + + ) : ( + + + + + + + + + + + + ); +}; + +export default OverviewCluster; diff --git a/console/ui/src/pages/settings/index.ts b/console/ui/src/pages/settings/index.ts new file mode 100644 index 000000000..b6b138be8 --- /dev/null +++ b/console/ui/src/pages/settings/index.ts @@ -0,0 +1,3 @@ +import Settings from '@pages/settings/ui'; + +export default Settings; diff --git a/console/ui/src/pages/settings/model/constants.ts b/console/ui/src/pages/settings/model/constants.ts new file mode 100644 index 000000000..57f3e7d6c --- /dev/null +++ b/console/ui/src/pages/settings/model/constants.ts @@ -0,0 +1,20 @@ +import RouterPaths from '@app/router/routerPathsConfig'; + +export const settingsTabsContent = [ + { + translateKey: 'generalSettings', + path: RouterPaths.settings.general.absolutePath, + }, + { + translateKey: 'secrets', + path: RouterPaths.settings.secrets.absolutePath, + }, + { + translateKey: 'projects', + path: RouterPaths.settings.projects.absolutePath, + }, + { + translateKey: 'environments', + path: RouterPaths.settings.environments.absolutePath, + }, +]; diff --git a/console/ui/src/pages/settings/ui/index.tsx b/console/ui/src/pages/settings/ui/index.tsx new file mode 100644 index 000000000..c9b54b0d4 --- /dev/null +++ b/console/ui/src/pages/settings/ui/index.tsx @@ -0,0 +1,31 @@ +import { FC } from 'react'; +import { Divider, Tab, Tabs } from '@mui/material'; +import { Link, Outlet, useLocation } from 'react-router-dom'; +import { settingsTabsContent } from '@pages/settings/model/constants.ts'; +import { useTranslation } from 'react-i18next'; +import { generateAbsoluteRouterPath } from '@shared/lib/functions.ts'; + +const Settings: FC = () => { + const { t } = useTranslation('settings'); + const location = useLocation(); + + return ( + <> + + {settingsTabsContent.map((tabContent) => ( + + ))} + + + + + ); +}; + +export default Settings; diff --git a/console/ui/src/shared/api/api/clusters.ts b/console/ui/src/shared/api/api/clusters.ts new file mode 100644 index 000000000..548555128 --- /dev/null +++ b/console/ui/src/shared/api/api/clusters.ts @@ -0,0 +1,268 @@ +import { baseApi as api } from '../baseApi.ts'; + +const injectedRtkApi = api.injectEndpoints({ + endpoints: (build) => ({ + postClusters: build.mutation({ + query: (queryArg) => ({ url: `/clusters`, method: 'POST', body: queryArg.requestClusterCreate }), + invalidatesTags: () => [{ type: 'Clusters', id: 'LIST' }], + }), + getClusters: build.query({ + query: (queryArg) => ({ + url: `/clusters`, + params: { + offset: queryArg.offset, + limit: queryArg.limit, + project_id: queryArg.projectId, + name: queryArg.name, + status: queryArg.status, + location: queryArg.location, + environment: queryArg.environment, + server_count: queryArg.serverCount, + postgres_version: queryArg.postgresVersion, + created_at_from: queryArg.createdAtFrom, + created_at_to: queryArg.createdAtTo, + sort_by: queryArg.sortBy, + }, + }), + providesTags: (result) => + result?.data + ? [...result.data.map(({ id }) => ({ type: 'Clusters', id })), { type: 'Clusters', id: 'LIST' }] + : [{ type: 'Clusters', id: 'LIST' }], + }), + getClustersDefaultName: build.query({ + query: () => ({ url: `/clusters/default_name` }), + keepUnusedDataFor: 0, + }), + getClustersById: build.query({ + query: (queryArg) => ({ url: `/clusters/${queryArg.id}` }), + providesTags: (result, error, { id }) => [{ type: 'Clusters', id }], + }), + deleteClustersById: build.mutation({ + query: (queryArg) => ({ url: `/clusters/${queryArg.id}`, method: 'DELETE' }), + invalidatesTags: () => [{ type: 'Clusters', id: 'LIST' }], + }), + postClustersByIdRefresh: build.mutation({ + query: (queryArg) => ({ url: `/clusters/${queryArg.id}/refresh`, method: 'POST' }), + invalidatesTags: (result, error, { id }) => [{ type: 'Clusters', id }], + }), + postClustersByIdReinit: build.mutation({ + query: (queryArg) => ({ + url: `/clusters/${queryArg.id}/reinit`, + method: 'POST', + body: queryArg.requestClusterReinit, + }), + invalidatesTags: (result, error, { id }) => [{ type: 'Clusters', id }], + }), + postClustersByIdReload: build.mutation({ + query: (queryArg) => ({ + url: `/clusters/${queryArg.id}/reload`, + method: 'POST', + body: queryArg.requestClusterReload, + }), + invalidatesTags: (result, error, { id }) => [{ type: 'Clusters', id }], + }), + postClustersByIdRestart: build.mutation({ + query: (queryArg) => ({ + url: `/clusters/${queryArg.id}/restart`, + method: 'POST', + body: queryArg.requestClusterRestart, + }), + invalidatesTags: (result, error, { id }) => [{ type: 'Clusters', id }], + }), + postClustersByIdStop: build.mutation({ + query: (queryArg) => ({ + url: `/clusters/${queryArg.id}/stop`, + method: 'POST', + body: queryArg.requestClusterStop, + }), + invalidatesTags: (result, error, { id }) => [{ type: 'Clusters', id }], + }), + postClustersByIdStart: build.mutation({ + query: (queryArg) => ({ + url: `/clusters/${queryArg.id}/start`, + method: 'POST', + body: queryArg.requestClusterStart, + }), + invalidatesTags: (result, error, { id }) => [{ type: 'Clusters', id }], + }), + postClustersByIdRemove: build.mutation({ + query: (queryArg) => ({ + url: `/clusters/${queryArg.id}/remove`, + method: 'POST', + body: queryArg.requestClusterRemove, + }), + invalidatesTags: () => [{ type: 'Clusters', id: 'LIST' }], + }), + }), + overrideExisting: false, +}); +export { injectedRtkApi as clustersApi }; +export type PostClustersApiResponse = /** status 200 OK */ ResponseClusterCreate; +export type PostClustersApiArg = { + requestClusterCreate: RequestClusterCreate; +}; +export type GetClustersApiResponse = /** status 200 OK */ ResponseClustersInfo; +export type GetClustersApiArg = { + offset?: number; + limit?: number; + projectId: number; + /** Filter by name */ + name?: string; + /** Filter by status */ + status?: string; + /** Filter by location */ + location?: string; + /** Filter by environment */ + environment?: string; + /** Filter by server_count */ + serverCount?: number; + /** Filter by postgres_version */ + postgresVersion?: number; + /** Created at after this date */ + createdAtFrom?: string; + /** Created at till this date */ + createdAtTo?: string; + /** Sort by fields. Example: sort_by=id,-name,created_at,updated_at + Supported values: + - id + - name + - created_at + - updated_at + - environment + - project + - status + - location + - server_count + - postgres_version + */ + sortBy?: string; +}; +export type GetClustersDefaultNameApiResponse = /** status 200 OK */ ResponseClusterDefaultName; +export type GetClustersDefaultNameApiArg = void; +export type GetClustersByIdApiResponse = /** status 200 OK */ ClusterInfo; +export type GetClustersByIdApiArg = { + id: number; +}; +export type DeleteClustersByIdApiResponse = /** status 204 OK */ void; +export type DeleteClustersByIdApiArg = { + id: number; +}; +export type PostClustersByIdRefreshApiResponse = /** status 200 OK */ ClusterInfo; +export type PostClustersByIdRefreshApiArg = { + id: number; +}; +export type PostClustersByIdReinitApiResponse = /** status 200 OK */ ResponseClusterCreate; +export type PostClustersByIdReinitApiArg = { + id: number; + requestClusterReinit: RequestClusterReinit; +}; +export type PostClustersByIdReloadApiResponse = /** status 200 OK */ ResponseClusterCreate; +export type PostClustersByIdReloadApiArg = { + id: number; + requestClusterReload: RequestClusterReload; +}; +export type PostClustersByIdRestartApiResponse = /** status 200 OK */ ResponseClusterCreate; +export type PostClustersByIdRestartApiArg = { + id: number; + requestClusterRestart: RequestClusterRestart; +}; +export type PostClustersByIdStopApiResponse = /** status 200 OK */ ResponseClusterCreate; +export type PostClustersByIdStopApiArg = { + id: number; + requestClusterStop: RequestClusterStop; +}; +export type PostClustersByIdStartApiResponse = /** status 200 OK */ ResponseClusterCreate; +export type PostClustersByIdStartApiArg = { + id: number; + requestClusterStart: RequestClusterStart; +}; +export type PostClustersByIdRemoveApiResponse = /** status 204 OK */ void; +export type PostClustersByIdRemoveApiArg = { + id: number; + requestClusterRemove: RequestClusterRemove; +}; +export type ResponseClusterCreate = { + /** unique code for cluster */ + cluster_id?: number; +}; +export type ErrorObject = { + code?: number; + title?: string; + description?: string; +}; +export type RequestClusterCreate = { + name?: string; + /** Info about cluster */ + description?: string; + /** Info for deployment system authorization */ + auth_info?: { + secret_id?: number; + }; + /** Project for new cluster */ + project_id?: number; + /** Project environment */ + environment_id?: number; + envs?: string[]; + extra_vars?: string[]; +}; +export type ClusterInfoInstance = { + id?: number; + name?: string; + ip?: string; + status?: string; + role?: string; + timeline?: number | null; + lag?: number | null; + tags?: object; + pending_restart?: boolean | null; +}; +export type ClusterInfo = { + id?: number; + name?: string; + description?: string; + status?: string; + creation_time?: string; + environment?: string; + servers?: ClusterInfoInstance[]; + postgres_version?: number; + /** Code of location */ + cluster_location?: string; + /** Project for cluster */ + project_name?: string; + connection_info?: object; +}; +export type PaginationInfoForListRequests = { + offset?: number | null; + limit?: number | null; + count?: number | null; +}; +export type ResponseClustersInfo = { + data?: ClusterInfo[]; + meta?: PaginationInfoForListRequests; +}; +export type ResponseClusterDefaultName = { + name?: string; +}; +export type RequestClusterReinit = object; +export type RequestClusterReload = object; +export type RequestClusterRestart = object; +export type RequestClusterStop = object; +export type RequestClusterStart = object; +export type RequestClusterRemove = object; +export const { + usePostClustersMutation, + useGetClustersQuery, + useLazyGetClustersQuery, + useGetClustersDefaultNameQuery, + useLazyGetClustersDefaultNameQuery, + useGetClustersByIdQuery, + useLazyGetClustersByIdQuery, + useDeleteClustersByIdMutation, + usePostClustersByIdRefreshMutation, + usePostClustersByIdReinitMutation, + usePostClustersByIdReloadMutation, + usePostClustersByIdRestartMutation, + usePostClustersByIdStopMutation, + usePostClustersByIdStartMutation, + usePostClustersByIdRemoveMutation, +} = injectedRtkApi; diff --git a/console/ui/src/shared/api/api/deployments.ts b/console/ui/src/shared/api/api/deployments.ts new file mode 100644 index 000000000..87fdca634 --- /dev/null +++ b/console/ui/src/shared/api/api/deployments.ts @@ -0,0 +1,94 @@ +import { baseApi as api } from '../baseApi.ts'; + +const injectedRtkApi = api.injectEndpoints({ + endpoints: (build) => ({ + getExternalDeployments: build.query({ + query: (queryArg) => ({ + url: `/external/deployments`, + params: { offset: queryArg.offset, limit: queryArg.limit }, + }), + }), + }), + overrideExisting: false, +}); +export { injectedRtkApi as deploymentsApi }; +export type GetExternalDeploymentsApiResponse = /** status 200 OK */ DeploymentsInfo; +export type GetExternalDeploymentsApiArg = { + offset?: number; + limit?: number; +}; +export type DeploymentCloudImage = { + image?: object; + arch?: string; + os_name?: string; + os_version?: string; + updated_at?: string; +}; +export type DeploymentInfoCloudRegion = { + /** unique parameter for DB */ + code?: string; + /** Field for web */ + name?: string; + /** List of datacenters for this region */ + datacenters?: { + code?: string; + location?: string; + cloud_image?: DeploymentCloudImage; + }[]; +}; +export type DeploymentInstanceType = { + code?: string; + cpu?: number; + ram?: number; + /** Price for 1 instance by hour */ + price_hourly?: number; + /** Price for 1 instance by month */ + price_monthly?: number; + /** Price currency */ + currency?: string; +}; +export type ResponseDeploymentInfo = { + code?: string; + description?: string; + avatar_url?: string; + /** List of available regions for current deployment */ + cloud_regions?: DeploymentInfoCloudRegion[]; + /** Lists of available instance types */ + instance_types?: { + small?: DeploymentInstanceType[] | null; + medium?: DeploymentInstanceType[]; + large?: DeploymentInstanceType[]; + }; + /** Hardware disks info */ + volumes?: { + /** Volume type */ + volume_type?: string; + /** Volume description */ + volume_description?: string; + /** Sets in GB */ + min_size?: number; + /** Sets in GB */ + max_size?: number; + /** Price for disk by months */ + price_monthly?: number; + /** Price currency */ + currency?: string; + /** Default volume */ + is_default?: boolean | null; + }[]; +}; +export type PaginationInfoForListRequests = { + offset?: number | null; + limit?: number | null; + count?: number | null; +}; +export type DeploymentsInfo = { + data?: ResponseDeploymentInfo[]; + meta?: PaginationInfoForListRequests; +}; +export type ErrorObject = { + code?: number; + title?: string; + description?: string; +}; +export const { useGetExternalDeploymentsQuery, useLazyGetExternalDeploymentsQuery } = injectedRtkApi; diff --git a/console/ui/src/shared/api/api/environments.ts b/console/ui/src/shared/api/api/environments.ts new file mode 100644 index 000000000..5fedef2e5 --- /dev/null +++ b/console/ui/src/shared/api/api/environments.ts @@ -0,0 +1,70 @@ +import { baseApi as api } from '../baseApi.ts'; + +const injectedRtkApi = api.injectEndpoints({ + endpoints: (build) => ({ + getEnvironments: build.query({ + query: (queryArg) => ({ url: `/environments`, params: { limit: queryArg.limit, offset: queryArg.offset } }), + providesTags: (result) => + result?.data + ? [ + ...result.data.map(({ id }) => ({ type: 'Environments', id }) as const), + { type: 'Environments', id: 'LIST' }, + ] + : [{ type: 'Environments', id: 'LIST' }], + }), + postEnvironments: build.mutation({ + query: (queryArg) => ({ url: `/environments`, method: 'POST', body: queryArg.requestEnvironment }), + invalidatesTags: () => [{ type: 'Environments', id: 'LIST' }], + }), + deleteEnvironmentsById: build.mutation({ + query: (queryArg) => ({ url: `/environments/${queryArg.id}`, method: 'DELETE' }), + invalidatesTags: () => [{ type: 'Environments', id: 'LIST' }], + }), + }), + overrideExisting: false, +}); +export { injectedRtkApi as environmentsApi }; +export type GetEnvironmentsApiResponse = /** status 200 OK */ ResponseEnvironmentsList; +export type GetEnvironmentsApiArg = { + limit?: number; + offset?: number; +}; +export type PostEnvironmentsApiResponse = /** status 200 OK */ ResponseEnvironment; +export type PostEnvironmentsApiArg = { + requestEnvironment: RequestEnvironment; +}; +export type DeleteEnvironmentsByIdApiResponse = /** status 204 OK */ void; +export type DeleteEnvironmentsByIdApiArg = { + id: number; +}; +export type ResponseEnvironment = { + id?: number; + name?: string; + description?: string | null; + created_at?: string; + updated_at?: string | null; +}; +export type PaginationInfoForListRequests = { + offset?: number | null; + limit?: number | null; + count?: number | null; +}; +export type ResponseEnvironmentsList = { + data?: ResponseEnvironment[]; + meta?: PaginationInfoForListRequests; +}; +export type ErrorObject = { + code?: number; + title?: string; + description?: string; +}; +export type RequestEnvironment = { + name?: string; + description?: string; +}; +export const { + useGetEnvironmentsQuery, + useLazyGetEnvironmentsQuery, + usePostEnvironmentsMutation, + useDeleteEnvironmentsByIdMutation, +} = injectedRtkApi; diff --git a/console/ui/src/shared/api/api/operations.ts b/console/ui/src/shared/api/api/operations.ts new file mode 100644 index 000000000..b8517ebb1 --- /dev/null +++ b/console/ui/src/shared/api/api/operations.ts @@ -0,0 +1,89 @@ +import { baseApi as api } from '../baseApi.ts'; + +const injectedRtkApi = api.injectEndpoints({ + endpoints: (build) => ({ + getOperations: build.query({ + query: (queryArg) => ({ + url: `/operations`, + params: { + project_id: queryArg.projectId, + start_date: queryArg.startDate, + end_date: queryArg.endDate, + cluster_name: queryArg.clusterName, + type: queryArg['type'], + status: queryArg.status, + sort_by: queryArg.sortBy, + limit: queryArg.limit, + offset: queryArg.offset, + }, + }), + providesTags: (result) => + result?.data + ? [...result.data.map(({ id }) => ({ type: 'Operations', id }) as const), { type: 'Operations', id: 'LIST' }] + : [{ type: 'Operations', id: 'LIST' }], + }), + getOperationsByIdLog: build.query({ + query: (queryArg) => ({ url: `/operations/${queryArg.id}/log` }), + transformResponse: (response, meta) => ({ + log: response, + isComplete: meta.response.headers.get('x-log-completed')?.toString() === 'true', + }), + providesTags: (result, error, { id }) => [{ type: 'Operations', id }], + }), + }), + overrideExisting: false, +}); +export { injectedRtkApi as operationsApi }; +export type GetOperationsApiResponse = /** status 200 OK */ ResponseOperationsList; +export type GetOperationsApiArg = { + /** Required parameter for filter */ + projectId: number; + /** Operations started after this date */ + startDate: string; + /** Operations started till this date */ + endDate: string; + /** Filter by cluster_name */ + clusterName?: string; + /** Filter by type */ + type?: string; + /** Filter by status */ + status?: string; + /** Sort by fields. Example: sort_by=cluster_name,-type,status,id */ + sortBy?: string; + limit?: number; + offset?: number; +}; +export type GetOperationsByIdLogApiResponse = /** status 200 OK */ string; +export type GetOperationsByIdLogApiArg = { + /** Operation id */ + id: number; +}; +export type ResponseOperation = { + id?: number; + cluster_name?: string; + started?: string; + finished?: string | null; + type?: string; + status?: string; + environment?: string; +}; +export type PaginationInfoForListRequests = { + offset?: number | null; + limit?: number | null; + count?: number | null; +}; +export type ResponseOperationsList = { + data?: ResponseOperation[]; + meta?: PaginationInfoForListRequests; +}; +export type ErrorObject = { + code?: number; + title?: string; + description?: string; +}; +export const { + useGetOperationsQuery, + useLazyGetOperationsQuery, + useGetOperationsByIdLogQuery, + useLazyGetOperationsByIdLogQuery, +} = injectedRtkApi; diff --git a/console/ui/src/shared/api/api/other.ts b/console/ui/src/shared/api/api/other.ts new file mode 100644 index 000000000..dcc529c88 --- /dev/null +++ b/console/ui/src/shared/api/api/other.ts @@ -0,0 +1,86 @@ +import { baseApi as api } from '../baseApi.ts'; + +const injectedRtkApi = api.injectEndpoints({ + endpoints: (build) => ({ + getVersion: build.query({ + query: () => ({ url: `/version` }), + }), + getDatabaseExtensions: build.query({ + query: (queryArg) => ({ + url: `/database/extensions`, + params: { + offset: queryArg.offset, + limit: queryArg.limit, + extension_type: queryArg.extensionType, + postgres_version: queryArg.postgresVersion, + }, + }), + }), + getPostgresVersions: build.query({ + query: () => ({ url: `/postgres_versions` }), + }), + deleteServersById: build.mutation({ + query: (queryArg) => ({ url: `/servers/${queryArg.id}`, method: 'DELETE' }), + }), + }), + overrideExisting: false, +}); +export { injectedRtkApi as otherApi }; +export type GetVersionApiResponse = /** status 200 OK */ VersionResponse; +export type GetVersionApiArg = void; +export type GetDatabaseExtensionsApiResponse = /** status 200 OK */ ResponseDatabaseExtensions; +export type GetDatabaseExtensionsApiArg = { + offset?: number; + limit?: number; + extensionType?: 'all' | 'contrib' | 'third_party'; + postgresVersion?: string; +}; +export type GetPostgresVersionsApiResponse = /** status 200 OK */ ResponsePostgresVersions; +export type GetPostgresVersionsApiArg = void; +export type DeleteServersByIdApiResponse = /** status 204 OK */ void; +export type DeleteServersByIdApiArg = { + id: number; +}; +export type VersionResponse = { + version?: string; +}; +export type ResponseDatabaseExtension = { + name?: string; + description?: string | null; + url?: string | null; + image?: string | null; + postgres_min_version?: string | null; + postgres_max_version?: string | null; + contrib?: boolean; +}; +export type PaginationInfoForListRequests = { + offset?: number | null; + limit?: number | null; + count?: number | null; +}; +export type ResponseDatabaseExtensions = { + data?: ResponseDatabaseExtension[]; + meta?: PaginationInfoForListRequests; +}; +export type ErrorObject = { + code?: number; + title?: string; + description?: string; +}; +export type ResponsePostgresVersion = { + major_version?: number; + release_date?: string; + end_of_life?: string; +}; +export type ResponsePostgresVersions = { + data?: ResponsePostgresVersion[]; +}; +export const { + useGetVersionQuery, + useLazyGetVersionQuery, + useGetDatabaseExtensionsQuery, + useLazyGetDatabaseExtensionsQuery, + useGetPostgresVersionsQuery, + useLazyGetPostgresVersionsQuery, + useDeleteServersByIdMutation, +} = injectedRtkApi; diff --git a/console/ui/src/shared/api/api/projects.ts b/console/ui/src/shared/api/api/projects.ts new file mode 100644 index 000000000..08642f759 --- /dev/null +++ b/console/ui/src/shared/api/api/projects.ts @@ -0,0 +1,81 @@ +import { baseApi as api } from '../baseApi.ts'; + +const injectedRtkApi = api.injectEndpoints({ + endpoints: (build) => ({ + postProjects: build.mutation({ + query: (queryArg) => ({ url: `/projects`, method: 'POST', body: queryArg.requestProjectCreate }), + invalidatesTags: () => [{ type: 'Projects', id: 'LIST' }], + }), + getProjects: build.query({ + query: (queryArg) => ({ url: `/projects`, params: { limit: queryArg.limit, offset: queryArg.offset } }), + providesTags: (result) => + result?.data + ? [...result.data.map(({ id }) => ({ type: 'Projects', id }) as const), { type: 'Projects', id: 'LIST' }] + : [{ type: 'Projects', id: 'LIST' }], + }), + patchProjectsById: build.mutation({ + query: (queryArg) => ({ url: `/projects/${queryArg.id}`, method: 'PATCH', body: queryArg.requestProjectPatch }), + invalidatesTags: (result, error, { id }) => [{ type: 'Projects', id }], + }), + deleteProjectsById: build.mutation({ + query: (queryArg) => ({ url: `/projects/${queryArg.id}`, method: 'DELETE' }), + invalidatesTags: () => [{ type: 'Projects', id: 'LIST' }], + }), + }), + overrideExisting: false, +}); +export { injectedRtkApi as projectsApi }; +export type PostProjectsApiResponse = /** status 200 OK */ ResponseProject; +export type PostProjectsApiArg = { + requestProjectCreate: RequestProjectCreate; +}; +export type GetProjectsApiResponse = /** status 200 OK */ ResponseProjectsList; +export type GetProjectsApiArg = { + limit?: number; + offset?: number; +}; +export type PatchProjectsByIdApiResponse = /** status 200 OK */ ResponseProject; +export type PatchProjectsByIdApiArg = { + id: number; + requestProjectPatch: RequestProjectPatch; +}; +export type DeleteProjectsByIdApiResponse = /** status 204 OK */ void; +export type DeleteProjectsByIdApiArg = { + id: number; +}; +export type ResponseProject = { + id?: number; + name?: string; + description?: string | null; + created_at?: string; + updated_at?: string | null; +}; +export type ErrorObject = { + code?: number; + title?: string; + description?: string; +}; +export type RequestProjectCreate = { + name?: string; + description?: string; +}; +export type PaginationInfoForListRequests = { + offset?: number | null; + limit?: number | null; + count?: number | null; +}; +export type ResponseProjectsList = { + data?: ResponseProject[]; + meta?: PaginationInfoForListRequests; +}; +export type RequestProjectPatch = { + name?: string | null; + description?: string | null; +}; +export const { + usePostProjectsMutation, + useGetProjectsQuery, + useLazyGetProjectsQuery, + usePatchProjectsByIdMutation, + useDeleteProjectsByIdMutation, +} = injectedRtkApi; diff --git a/console/ui/src/shared/api/api/secrets.ts b/console/ui/src/shared/api/api/secrets.ts new file mode 100644 index 000000000..b9b385499 --- /dev/null +++ b/console/ui/src/shared/api/api/secrets.ts @@ -0,0 +1,164 @@ +import { baseApi as api } from '../baseApi.ts'; + +const injectedRtkApi = api.injectEndpoints({ + endpoints: (build) => ({ + postSecrets: build.mutation({ + query: (queryArg) => ({ url: `/secrets`, method: 'POST', body: queryArg.requestSecretCreate }), + invalidatesTags: () => [{ type: 'Secrets', id: 'LIST' }], + }), + getSecrets: build.query({ + query: (queryArg) => ({ + url: `/secrets`, + params: { + limit: queryArg.limit, + offset: queryArg.offset, + project_id: queryArg.projectId, + name: queryArg.name, + type: queryArg.type, + sort_by: queryArg.sortBy, + }, + }), + providesTags: (result) => + result?.data + ? [...result.data.map(({ id }) => ({ type: 'Secrets', id }) as const), { type: 'Secrets', id: 'LIST' }] + : [{ type: 'Secrets', id: 'LIST' }], + }), + patchSecretsById: build.mutation({ + query: (queryArg) => ({ url: `/secrets/${queryArg.id}`, method: 'PATCH', body: queryArg.requestSecretPatch }), + invalidatesTags: (result, error, { id }) => [{ type: 'Secrets', id }], + }), + deleteSecretsById: build.mutation({ + query: (queryArg) => ({ url: `/secrets/${queryArg.id}`, method: 'DELETE' }), + invalidatesTags: () => [{ type: 'Secrets', id: 'LIST' }], + }), + }), + overrideExisting: false, +}); +export { injectedRtkApi as secretsApi }; +export type PostSecretsApiResponse = /** status 200 OK */ ResponseSecretInfo; + +export interface PostSecretsApiArg { + requestSecretCreate: RequestSecretCreate; +} + +export type GetSecretsApiResponse = /** status 200 OK */ ResponseSecretInfoList; + +export interface GetSecretsApiArg { + limit?: number; + offset?: number; + projectId: number; + /** Filter by name */ + name?: string; + /** Filter by type */ + type?: string; + /** Sort by fields. Example: sort_by=id,name,-type */ + sortBy?: string; +} + +export type PatchSecretsByIdApiResponse = /** status 200 OK */ ResponseSecretInfo; + +export interface PatchSecretsByIdApiArg { + id: number; + requestSecretPatch: RequestSecretPatch; +} + +export type DeleteSecretsByIdApiResponse = /** status 204 OK */ void; + +export interface DeleteSecretsByIdApiArg { + id: number; +} + +export type SecretType = 'aws' | 'gcp' | 'hetzner' | 'ssh_key' | 'digitalocean' | 'password' | 'azure'; + +export interface ResponseSecretInfo { + id?: number; + project_id?: number; + name?: string; + type?: SecretType; + created_at?: string; + updated_at?: string | null; + is_used?: boolean; + used_by_clusters?: string | null; +} + +export interface ErrorObject { + code?: number; + title?: string; + description?: string; +} + +export interface RequestSecretValueAws { + AWS_ACCESS_KEY_ID?: string; + AWS_SECRET_ACCESS_KEY?: string; +} + +export interface RequestSecretValueGcp { + GCP_SERVICE_ACCOUNT_CONTENTS?: string; +} + +export interface RequestSecretValueHetzner { + HCLOUD_API_TOKEN?: string; +} + +export interface RequestSecretValueSshKey { + SSH_PRIVATE_KEY?: string; +} + +export interface RequestSecretValueDigitalOcean { + DO_API_TOKEN?: string; +} + +export interface RequestSecretValuePassword { + USERNAME?: string; + PASSWORD?: string; +} + +export interface RequestSecretValueAzure { + AZURE_SUBSCRIPTION_ID?: string; + AZURE_CLIENT_ID?: string; + AZURE_SECRET?: string; + AZURE_TENANT?: string; +} + +export interface RequestSecretValue { + aws?: RequestSecretValueAws; + gcp?: RequestSecretValueGcp; + hetzner?: RequestSecretValueHetzner; + ssh_key?: RequestSecretValueSshKey; + digitalocean?: RequestSecretValueDigitalOcean; + password?: RequestSecretValuePassword; + azure?: RequestSecretValueAzure; +} + +export interface RequestSecretCreate { + project_id?: number; + name?: string; + type?: SecretType; + value?: RequestSecretValue; +} + +export interface PaginationInfoForListRequests { + offset?: number | null; + limit?: number | null; + count?: number | null; +} + +export interface ResponseSecretInfoList { + data?: ResponseSecretInfo[]; + meta?: PaginationInfoForListRequests; +} + +export interface RequestSecretPatch { + name?: string | null; + type?: string | null; + /** Secret value in base64 */ + value?: string | null; +} + +export const { + usePostSecretsMutation, + useGetSecretsQuery, + useLazyGetSecretsQuery, + usePatchSecretsByIdMutation, + useDeleteSecretsByIdMutation, +} = injectedRtkApi; diff --git a/console/ui/src/shared/api/api/settings.ts b/console/ui/src/shared/api/api/settings.ts new file mode 100644 index 000000000..e40d90f83 --- /dev/null +++ b/console/ui/src/shared/api/api/settings.ts @@ -0,0 +1,76 @@ +import { baseApi as api } from '../baseApi.ts'; + +const injectedRtkApi = api.injectEndpoints({ + endpoints: (build) => ({ + postSettings: build.mutation({ + query: (queryArg) => ({ url: `/settings`, method: 'POST', body: queryArg.requestCreateSetting }), + invalidatesTags: () => [{ type: 'Settings', id: 'LIST' }], + }), + getSettings: build.query({ + query: (queryArg) => ({ + url: `/settings`, + params: { name: queryArg.name, offset: queryArg.offset, limit: queryArg.limit }, + }), + providesTags: (result) => + result?.data + ? [...result.data.map(({ id }) => ({ type: 'Settings', id }) as const), { type: 'Settings', id: 'LIST' }] + : [{ type: 'Settings', id: 'LIST' }], + }), + patchSettingsByName: build.mutation({ + query: (queryArg) => ({ + url: `/settings/${queryArg.name}`, + method: 'PATCH', + body: queryArg.requestChangeSetting, + }), + invalidatesTags: (result, error, { id }) => [{ type: 'Settings', id }], + }), + }), + overrideExisting: false, +}); +export { injectedRtkApi as settingsApi }; +export type PostSettingsApiResponse = /** status 200 OK */ ResponseSetting; +export type PostSettingsApiArg = { + requestCreateSetting: RequestCreateSetting; +}; +export type GetSettingsApiResponse = /** status 200 OK */ ResponseSettings; +export type GetSettingsApiArg = { + /** Filter by name */ + name?: string; + offset?: number; + limit?: number; +}; +export type PatchSettingsByNameApiResponse = /** status 200 OK */ ResponseSetting; +export type PatchSettingsByNameApiArg = { + name: string; + requestChangeSetting: RequestChangeSetting; +}; +export type ResponseSetting = { + id?: number; + name?: string; + value?: object; + created_at?: string; + updated_at?: string | null; +}; +export type ErrorObject = { + code?: number; + title?: string; + description?: string; +}; +export type RequestCreateSetting = { + name?: string; + value?: object; +}; +export type PaginationInfoForListRequests = { + offset?: number | null; + limit?: number | null; + count?: number | null; +}; +export type ResponseSettings = { + data?: ResponseSetting[]; + mete?: PaginationInfoForListRequests; +}; +export type RequestChangeSetting = { + value?: object | null; +}; +export const { usePostSettingsMutation, useGetSettingsQuery, useLazyGetSettingsQuery, usePatchSettingsByNameMutation } = + injectedRtkApi; diff --git a/console/ui/src/shared/api/apiConfig.ts b/console/ui/src/shared/api/apiConfig.ts new file mode 100644 index 000000000..bb3d13457 --- /dev/null +++ b/console/ui/src/shared/api/apiConfig.ts @@ -0,0 +1,45 @@ +import type { ConfigFile } from '@rtk-query/codegen-openapi'; + +const config: ConfigFile = { + schemaFile: '../../../../service/api/swagger.yaml', + apiFile: './baseApi.ts', + apiImport: 'baseApi', + outputFiles: { + './generatedApi/clusters.ts': { + filterEndpoints: [/cluster/i], + exportName: 'clustersApi', + }, + './generatedApi/environments.ts': { + filterEndpoints: [/environment/i], + exportName: 'environmentsApi', + }, + './generatedApi/projects.ts': { + filterEndpoints: [/project/i], + exportName: 'projectsApi', + }, + './generatedApi/secrets.ts': { + filterEndpoints: [/secret/i], + exportName: 'secretsApi', + }, + './generatedApi/operations.ts': { + filterEndpoints: [/operation/i], + exportName: 'operationsApi', + }, + './generatedApi/deployments.ts': { + filterEndpoints: [/deployment/i], + exportName: 'deploymentsApi', + }, + './generatedApi/settings.ts': { + filterEndpoints: [/settings/i], + exportName: 'settingsApi', + }, + './generatedApi/other.ts': { + filterEndpoints: [/^((?!(cluster|environment|project|secret|operation|deployment|settings)).)*$/i], + exportName: 'otherApi', + }, + }, + exportName: 'postgresClusterConsoleApi', + hooks: { queries: true, lazyQueries: true, mutations: true }, +}; + +export default config; diff --git a/console/ui/src/shared/api/baseApi.ts b/console/ui/src/shared/api/baseApi.ts new file mode 100644 index 000000000..efdc6d703 --- /dev/null +++ b/console/ui/src/shared/api/baseApi.ts @@ -0,0 +1,16 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { API_URL } from '@shared/config/constants.ts'; +import i18n from 'i18next'; + +export const baseApi = createApi({ + baseQuery: fetchBaseQuery({ + baseUrl: API_URL as string, + prepareHeaders: (headers, { endpoint }) => { + headers.set('Accept-Language', i18n.language); + if (endpoint !== 'login') headers.set('Authorization', `Bearer ${String(localStorage.getItem('token'))}`); + return headers; + }, + }), + tagTypes: ['Clusters', 'Operations', 'Secrets', 'Projects', 'Environments', 'Settings'], + endpoints: () => ({}), +}); diff --git a/console/ui/src/shared/api/enhancedSecretsApi.ts b/console/ui/src/shared/api/enhancedSecretsApi.ts new file mode 100644 index 000000000..a51cbdaa2 --- /dev/null +++ b/console/ui/src/shared/api/enhancedSecretsApi.ts @@ -0,0 +1,15 @@ +import { secretsApi } from '@shared/api/api/secrets.ts'; + +const enhancedSecretsApi = secretsApi.enhanceEndpoints({ + addTagTypes: ['Secrets'], + endpoints: { + getSecrets: { + providesTags: ['Secrets'], + }, + postSecrets: { + invalidatesTags: ['Secrets'], + }, + }, +}); + +export default enhancedSecretsApi; diff --git a/console/ui/src/shared/assets/PGCLogo.svg b/console/ui/src/shared/assets/PGCLogo.svg new file mode 100644 index 000000000..a056ae882 --- /dev/null +++ b/console/ui/src/shared/assets/PGCLogo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/console/ui/src/shared/assets/calendarClockICon.svg b/console/ui/src/shared/assets/calendarClockICon.svg new file mode 100644 index 000000000..d7f110ca2 --- /dev/null +++ b/console/ui/src/shared/assets/calendarClockICon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/console/ui/src/shared/assets/checkIcon.svg b/console/ui/src/shared/assets/checkIcon.svg new file mode 100644 index 000000000..cb4912567 --- /dev/null +++ b/console/ui/src/shared/assets/checkIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/console/ui/src/shared/assets/clustersIcon.svg b/console/ui/src/shared/assets/clustersIcon.svg new file mode 100644 index 000000000..07dadf4fb --- /dev/null +++ b/console/ui/src/shared/assets/clustersIcon.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/console/ui/src/shared/assets/collapseIcon.svg b/console/ui/src/shared/assets/collapseIcon.svg new file mode 100644 index 000000000..028af5dec --- /dev/null +++ b/console/ui/src/shared/assets/collapseIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/console/ui/src/shared/assets/cpuIcon.svg b/console/ui/src/shared/assets/cpuIcon.svg new file mode 100644 index 000000000..a1584a84f --- /dev/null +++ b/console/ui/src/shared/assets/cpuIcon.svg @@ -0,0 +1,2 @@ + + diff --git a/console/ui/src/shared/assets/databaseIcon.svg b/console/ui/src/shared/assets/databaseIcon.svg new file mode 100644 index 000000000..4050d1013 --- /dev/null +++ b/console/ui/src/shared/assets/databaseIcon.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/console/ui/src/shared/assets/docsIcon.svg b/console/ui/src/shared/assets/docsIcon.svg new file mode 100644 index 000000000..30baf2753 --- /dev/null +++ b/console/ui/src/shared/assets/docsIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/console/ui/src/shared/assets/flagIcon.svg b/console/ui/src/shared/assets/flagIcon.svg new file mode 100644 index 000000000..f0bd3555f --- /dev/null +++ b/console/ui/src/shared/assets/flagIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/console/ui/src/shared/assets/githubIcon.svg b/console/ui/src/shared/assets/githubIcon.svg new file mode 100644 index 000000000..d9a232c77 --- /dev/null +++ b/console/ui/src/shared/assets/githubIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/console/ui/src/shared/assets/instanceIcon.svg b/console/ui/src/shared/assets/instanceIcon.svg new file mode 100644 index 000000000..cc86f34a8 --- /dev/null +++ b/console/ui/src/shared/assets/instanceIcon.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/console/ui/src/shared/assets/lanIcon.svg b/console/ui/src/shared/assets/lanIcon.svg new file mode 100644 index 000000000..d720f1904 --- /dev/null +++ b/console/ui/src/shared/assets/lanIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/console/ui/src/shared/assets/logoutIcon.svg b/console/ui/src/shared/assets/logoutIcon.svg new file mode 100644 index 000000000..ecd6beb3d --- /dev/null +++ b/console/ui/src/shared/assets/logoutIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/console/ui/src/shared/assets/memoryIcon.svg b/console/ui/src/shared/assets/memoryIcon.svg new file mode 100644 index 000000000..c17bf6a46 --- /dev/null +++ b/console/ui/src/shared/assets/memoryIcon.svg @@ -0,0 +1,2 @@ + + diff --git a/console/ui/src/shared/assets/operationsIcon.svg b/console/ui/src/shared/assets/operationsIcon.svg new file mode 100644 index 000000000..106e3b29a --- /dev/null +++ b/console/ui/src/shared/assets/operationsIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/console/ui/src/shared/assets/ramIcon.svg b/console/ui/src/shared/assets/ramIcon.svg new file mode 100644 index 000000000..ce5a9e135 --- /dev/null +++ b/console/ui/src/shared/assets/ramIcon.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/console/ui/src/shared/assets/serversIcon.svg b/console/ui/src/shared/assets/serversIcon.svg new file mode 100644 index 000000000..e0f3b08e6 --- /dev/null +++ b/console/ui/src/shared/assets/serversIcon.svg @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/console/ui/src/shared/assets/settingsIcon.svg b/console/ui/src/shared/assets/settingsIcon.svg new file mode 100644 index 000000000..59ba4ea07 --- /dev/null +++ b/console/ui/src/shared/assets/settingsIcon.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/console/ui/src/shared/assets/sponsorIcon.svg b/console/ui/src/shared/assets/sponsorIcon.svg new file mode 100644 index 000000000..53e1ee1a5 --- /dev/null +++ b/console/ui/src/shared/assets/sponsorIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/console/ui/src/shared/assets/storageIcon.svg b/console/ui/src/shared/assets/storageIcon.svg new file mode 100644 index 000000000..ba2cfa33d --- /dev/null +++ b/console/ui/src/shared/assets/storageIcon.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/console/ui/src/shared/assets/supportIcon.svg b/console/ui/src/shared/assets/supportIcon.svg new file mode 100644 index 000000000..805593207 --- /dev/null +++ b/console/ui/src/shared/assets/supportIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/console/ui/src/shared/config/constants.ts b/console/ui/src/shared/config/constants.ts new file mode 100644 index 000000000..a58153d65 --- /dev/null +++ b/console/ui/src/shared/config/constants.ts @@ -0,0 +1,32 @@ +import { getEnvVariable } from '@shared/lib/functions.ts'; + +export const LOCALES = Object.freeze({ + EN_US: 'en', +}); + +export const API_URL = getEnvVariable('VITE_API_URL'); +export const AUTH_TOKEN = getEnvVariable('VITE_AUTH_TOKEN'); +export const CLUSTERS_POLLING_INTERVAL = getEnvVariable('VITE_CLUSTERS_POLLING_INTERVAL'); +export const CLUSTER_OVERVIEW_POLLING_INTERVAL = getEnvVariable('VITE_CLUSTER_OVERVIEW_POLLING_INTERVAL'); +export const OPERATIONS_POLLING_INTERVAL = getEnvVariable('VITE_OPERATIONS_POLLING_INTERVAL'); +export const OPERATION_LOGS_POLLING_INTERVAL = getEnvVariable('VITE_OPERATION_LOGS_POLLING_INTERVAL'); + +export const PAGINATION_LIMIT_OPTIONS = Object.freeze([ + { value: 5, label: 5 }, + { value: 10, label: 10 }, + { + value: 25, + label: 25, + }, + { value: 50, label: 50 }, + { value: 100, label: 100 }, +]); + +export const PROVIDERS = Object.freeze({ + AWS: 'aws', + GCP: 'gcp', + AZURE: 'azure', + DIGITAL_OCEAN: 'digitalocean', + HETZNER: 'hetzner', + LOCAL: 'local', +}); diff --git a/console/ui/src/shared/i18n/i18n.ts b/console/ui/src/shared/i18n/i18n.ts new file mode 100644 index 000000000..1e931c145 --- /dev/null +++ b/console/ui/src/shared/i18n/i18n.ts @@ -0,0 +1,36 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import shared from './locales/en/shared.json'; +import operations from './locales/en/operations.json'; +import settings from './locales/en/settings.json'; +import clusters from './locales/en/clusters.json'; +import validation from './locales/en/validation.json'; +import toasts from './locales/en/toasts.json'; +import { LOCALES } from '../config/constants'; + +import LanguageDetector from 'i18next-browser-languagedetector'; + +const resources = { + en: { + shared, + clusters, + operations, + settings, + validation, + toasts, + }, +}; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + ns: ['shared', 'clusters', 'operations', 'settings', 'validation', 'toasts'], + fallbackLng: LOCALES.EN_US, + supportedLngs: Object.values(LOCALES), + returnNull: false, + debug: false, + }); + +export default i18n; diff --git a/console/ui/src/shared/i18n/locales/en/clusters.json b/console/ui/src/shared/i18n/locales/en/clusters.json new file mode 100644 index 000000000..9a9324537 --- /dev/null +++ b/console/ui/src/shared/i18n/locales/en/clusters.json @@ -0,0 +1,70 @@ +{ + "clusters": "Clusters", + "cluster": "Cluster", + "createCluster": "Create cluster", + "createPostgresCluster": "Create Postgres Cluster", + "clusterName": "Cluster name", + "creationTime": "Creation time", + "servers": "Servers", + "server": "Server", + "postgresVersion": "Postgres version", + "location": "Location", + "noPostgresClustersTitle": "No Postgres Clusters", + "noPostgresClustersLine1": "Deploy Postgres to supported cloud providers: AWS, GCP, Azure, DigitalOcean and Hetzner Cloud. All components are installed within your cloud account.\nOr Install on your existing resources, whether it's any other cloud or your own data center.\n\nTo get started, just click “{{createCluster}}” button.", + "selectDeploymentDestination": "Select deployment destination", + "clustersSearchPlaceholder": "Enter property name or value", + "selectCloudRegion": "Select cloud region", + "selectInstanceType": "Select instance type", + "numberOfInstances": "Number of instances", + "dataDiskStorage": "Data disk storage", + "sshPublicKey": "SSH public key", + "sshKey": "SSH key", + "sshKeyAuthDescription": "Connect to your services with an SSH key pair", + "passwordAuthDescription": "Connect to your services with an SSH user and password", + "description": "Description", + "sshKeyLocalMachinePlaceholder": "Paste your private SSH key here to access to the servers during deployment.\nEnsure that the corresponding public SSH key has been added to the ~/.ssh/authorized_keys file on all specified servers.", + "sshKeyCloudProviderPlaceholder": "Paste the SSH public keys here (one per line). These keys will be added to the database server's ~/.ssh/authorized_keys file.\nAt least one public key must be provided to ensure access to the server after deployment.", + "descriptionPlaceholder": "Optional. Here you can specify any additional information about the cluster or leave notes.", + "summary": "Summary", + "name": "Name", + "cloud": "Cloud", + "region": "Region", + "instanceType": "Instance type", + "estimatedMonthlyPrice": "Estimated monthly price", + "estimatedCostAdditionalInfo": "* Payment is made directly to the cloud provider.\nAn estimated cost is provided here. Please refer to the <0>official pricing page to confirm the actual costs.", + "yourOwn": "Your Own", + "machines": "Machines", + "yourOwnMachinesTooltip": "Use \"Your Own Machines\" to deploy the cluster on existing servers in another cloud provider or your own data center.", + "perServer": "per server", + "perDisk": "per disk", + "hostname": "Hostname", + "locationPlaceholder": "region/datacenter (optional)", + "databaseServers": "Database servers", + "authenticationMethod": "Authentication method", + "saveToConsole": "Save to console", + "clusterVipAddress": "Cluster VIP address", + "clusterVipAddressPlaceholder": "Optional. Specify the (unused) IP address here to provide a single entry point for client access to databases in the cluster. Not for cloud environments.", + "loadBalancers": "Load balancers", + "loadBalancing": "Load balancing", + "haproxyLoadBalancer": "HAProxy load balancer", + "haproxyLoadBalancerTooltip": "Deploy a HAProxy Load Balancer. This feature supports load balancing for read operations, facilitating effective scale-out strategies through the use of read-only replicas.", + "numberOfServers": "Number of servers", + "highAvailability": "High availability", + "highAvailabilityInfo": "*A minimum of 3 servers is required to ensure high availability.", + "host": "Host", + "role": "Role", + "state": "State", + "timeline": "Timeline", + "lagInMb": "Lag in MB", + "pendingRestart": "Pending restart", + "scheduledRestart": "Scheduled restart", + "tags": "Tags", + "connectionInfo": "Connection info", + "clusterInfo": "Cluster info", + "ipAddress": "IP Address", + "port": "Port", + "backups": "Backups", + "useDefinedSecret": "Use defined secret?", + "deleteClusterModalHeader": "Delete {{clusterName}}?", + "deleteClusterModalBody": "Are you sure you want to delete cluster \"{{clusterName}}\"?" +} diff --git a/console/ui/src/shared/i18n/locales/en/operations.json b/console/ui/src/shared/i18n/locales/en/operations.json new file mode 100644 index 000000000..6b63bc625 --- /dev/null +++ b/console/ui/src/shared/i18n/locales/en/operations.json @@ -0,0 +1,16 @@ +{ + "operations": "Operations", + "operation": "Operation", + "started": "Started", + "finished": "Finished", + "creationTime": "Creation time", + "type": "Type", + "showDetails": "Show details", + "lastDay": "Last day", + "lastWeek": "Last week", + "lastMonth": "Last month", + "lastThreeMonths": "Last three months", + "lastSixMonths": "Last six months", + "lastYear": "Last year", + "log": "Log" +} \ No newline at end of file diff --git a/console/ui/src/shared/i18n/locales/en/settings.json b/console/ui/src/shared/i18n/locales/en/settings.json new file mode 100644 index 000000000..3158a47a8 --- /dev/null +++ b/console/ui/src/shared/i18n/locales/en/settings.json @@ -0,0 +1,27 @@ +{ + "settings": "Settings", + "setting": "Setting", + "generalSettings": "General settings", + "environments": "Environments", + "environmentName": "Environment name", + "addEnvironment": "Add environment", + "secret": "Secret", + "secrets": "Secrets", + "proxyServer": "Proxy server", + "proxyServerInfo": "Specify your HTTP and HTTPS corporate proxy server settings below (optional).\nTo enable package downloads during cluster deployment in environments without direct internet access.", + "addSecret": "Add secret", + "secretType": "Secret type", + "secretName": "Secret name", + "addProject": "Add project", + "projectName": "Project name", + "settingsAwsSecretInfo": "Enter the access key to deploy the cluster to your AWS cloud provider account below. See the <0>official documentation for instructions on creating an access key.", + "settingsGcpSecretInfo": "Enter the service account content (in json or base64 format) to deploy the cluster to your Google Cloud provider account below. See the <0>official documentation for instructions on creating an service account key.", + "settingsDoSecretInfo": "Enter the API token to deploy the cluster to your DigitalOcean Cloud provider account below. See the <0>official documentation for instructions on creating an access token.", + "settingsAzureSecretInfo": "Enter the necessary details to deploy the cluster to your Azure Cloud provider account below. See the <0>official documentation for instructions on creating an service principal.", + "settingsHetznerSecretInfo": "Enter the API token to deploy the cluster to your Hetzner Cloud provider account below. See the <0>official documentation for instructions on creating an access token.", + "settingsSshKeySecretInfo": "Enter the contents of your private SSH key below to access the cluster servers via SSH. It is assumed that the corresponding public key has already been added to the servers.", + "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 diff --git a/console/ui/src/shared/i18n/locales/en/shared.json b/console/ui/src/shared/i18n/locales/en/shared.json new file mode 100644 index 000000000..084c0a52b --- /dev/null +++ b/console/ui/src/shared/i18n/locales/en/shared.json @@ -0,0 +1,45 @@ +{ + "logout": "Logout", + "github": "Github repository", + "documentation": "Documentation", + "support": "Support", + "sponsor": "Sponsor", + "404Title": "Nothing to see here", + "404Text": "Page you are trying to open does not exist. You may have mistyped the address, or the page has been moved to another URL.\nIf you think this is an error contact support.", + "404ButtonText": "Take me back to home page", + "removeFromList": "Remove from list", + "refresh": "Refresh", + "overview": "Overview", + "cancel": "Cancel", + "save": "Save", + "status": "Status", + "id": "ID", + "environment": "Environment", + "actions": "Actions", + "user": "User", + "username": "Username", + "password": "Password", + "project": "Project", + "on": "On", + "off": "Off", + "login": "Login", + "token": "Token", + "enterTokenPlaceholder": "Enter token", + "amount": "Amount", + "name": "Name", + "type": "Type", + "created": "Created", + "updated": "Updated", + "used": "Used", + "delete": "Delete", + "selectSecret": "Select secret", + "addNewProject": "Add new project", + "projectName": "Project name", + "enterNewProjectName": "Enter new project name", + "description": "Description", + "yes": "Yes", + "no": "No", + "add": "Add", + "defaultTableSearchPlaceholder": "Enter property name or value", + "address": "Address" +} \ No newline at end of file diff --git a/console/ui/src/shared/i18n/locales/en/toasts.json b/console/ui/src/shared/i18n/locales/en/toasts.json new file mode 100644 index 000000000..18114a15c --- /dev/null +++ b/console/ui/src/shared/i18n/locales/en/toasts.json @@ -0,0 +1,18 @@ +{ + "clusterSuccessfullyCreated": "Cluster {{clusterName}} creation initiated. Please wait until deployment is complete", + "clusterSuccessfullyRemoved": "Cluster {{clusterName}} successfully removed", + "secretSuccessfullyCreated": "Secret {{secretName}} successfully created", + "secretSuccessfullyRemoved": "Secret {{secretName}} successfully removed", + "settingsSuccessfullyChanged": "Settings successfully changed", + "secretsSecretIsUsed_one": "The secret cannot be deleted because it is currently being used by the following cluster: {{clusterNames}}", + "secretsSecretIsUsed_other": "The secret cannot be deleted because it is currently being used by the following clusters: {{clusterNames}}", + "projectSuccessfullyCreated": "Project {{projectName}} successfully created", + "projectSuccessfullyRemoved": "Project {{projectName}} successfully removed", + "cannotRemoveActiveProject": "Cannot remove active project", + "environmentSuccessfullyCreated": "Environment {{environmentName}} successfully created", + "environmentSuccessfullyRemoved": "Environment {{environmentName}} successfully removed", + "serverSuccessfullyRemoved": "Server {{serverName}} successfully removed", + "invalidToken": "Token is not valid", + "valueCopiedToClipboard": "Value copied to clipboard", + "failedToCopyToClipboard": "Failed to copy to clipboard" +} \ No newline at end of file diff --git a/console/ui/src/shared/i18n/locales/en/validation.json b/console/ui/src/shared/i18n/locales/en/validation.json new file mode 100644 index 000000000..48e06eb72 --- /dev/null +++ b/console/ui/src/shared/i18n/locales/en/validation.json @@ -0,0 +1,5 @@ +{ + "requiredField": "Required field", + "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" +} diff --git a/console/ui/src/shared/lib/functions.ts b/console/ui/src/shared/lib/functions.ts new file mode 100644 index 000000000..804698608 --- /dev/null +++ b/console/ui/src/shared/lib/functions.ts @@ -0,0 +1,43 @@ +import { generatePath, resolvePath } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import { format } from 'date-fns/format'; +import { isValid } from 'date-fns/isValid'; + +declare const window: any; + +/** + * Function generates absolute path that can be used for react-router "navigate" function. + * @param path - Absolute URN path. + * @param params - Additional params that will be substituted in URN dynamic values. + */ +export const generateAbsoluteRouterPath = (path: string, params?: Record) => + resolvePath(generatePath(path, params)); + +/** + * Function returns env variable passed to container or variable from .env* files. + * @param variableName - Name of a variable. + */ +export const getEnvVariable = (variableName: string) => window?._env_?.[variableName] ?? import.meta.env[variableName]; + +/** + * Function manages error event when performing request. + * @param e - Error event. + */ +export const handleRequestErrorCatch = (e) => { + console.error(e); + toast.error(e); +}; + +/** + * Function converts timestamp to easily readable string. + * @param timestamp - Timestamp to be converted. + */ +export const convertTimestampToReadableTime = (timestamp?: string) => + isValid(new Date(timestamp)) ? format(timestamp, 'MMM dd, yyyy, HH:mm:ss') : '-'; + +export const manageSortingOrder = ( + sorting: { + desc?: boolean; + id?: string; + }[], +) => (sorting?.desc ? `-${sorting?.id}` : sorting?.id); diff --git a/console/ui/src/shared/lib/hooks.tsx b/console/ui/src/shared/lib/hooks.tsx new file mode 100644 index 000000000..d25c0cb12 --- /dev/null +++ b/console/ui/src/shared/lib/hooks.tsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; + +/** + * Hook polls RTK query request every N seconds. + * @param request - RTK request to poll. + * @param pollingInterval - Number in milliseconds that represents polling interval. + * @param options - Different config options. + */ +export const useQueryPolling = (request: any, pollingInterval: number, options?: { stop?: boolean }) => { + const result = request(); + + useEffect(() => { + const polling = setInterval(() => result.refetch(), pollingInterval); + if (options?.stop?.toString() === 'true') clearInterval(polling); + return () => { + clearInterval(polling); + }; + }, [options]); + + return result; +}; + +/** + * Custom hook for copying value to clipboard. Returns copied value and function to copy value. + */ +export const useCopyToClipboard = (): [copiedText: string | null, copyFunction: (text: string) => Promise] => { + const { t } = useTranslation('toasts'); + const [copiedText, setCopiedText] = useState(null); + + const copyFunction = async (text) => { + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + } else { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + document.execCommand('copy'); + textArea.remove(); + } + setCopiedText(text); + toast.success(t('valueCopiedToClipboard')); + return true; + } catch (error) { + console.warn('Copy failed', error); + toast.error(t('failedToCopyToClipboard')); + setCopiedText(null); + return false; + } + }; + + return [copiedText, copyFunction]; +}; diff --git a/console/ui/src/shared/model/constants.ts b/console/ui/src/shared/model/constants.ts new file mode 100644 index 000000000..09db1a02b --- /dev/null +++ b/console/ui/src/shared/model/constants.ts @@ -0,0 +1,5 @@ +export const AUTHENTICATION_METHODS = Object.freeze({ + // changing names might break secrets POST request + SSH: 'ssh_key', + PASSWORD: 'password', +}); diff --git a/console/ui/src/shared/model/types.ts b/console/ui/src/shared/model/types.ts new file mode 100644 index 000000000..9dbbf4501 --- /dev/null +++ b/console/ui/src/shared/model/types.ts @@ -0,0 +1,6 @@ +import { MRT_Row, MRT_RowData } from 'material-react-table'; + +export interface TableRowActionsProps { + closeMenu: () => void; + row: MRT_Row; +} diff --git a/console/ui/src/shared/theme/theme.ts b/console/ui/src/shared/theme/theme.ts new file mode 100644 index 000000000..5cf939486 --- /dev/null +++ b/console/ui/src/shared/theme/theme.ts @@ -0,0 +1,36 @@ +import { createTheme } from '@mui/material'; +import { blue } from '@mui/material/colors'; +import { enUS } from '@mui/material/locale'; + +declare module '@mui/material/styles' { + interface PaletteColor { + lighter10?: string; + } + + interface SimplePaletteColorOptions { + lighter10?: string; + } +} + +const theme = createTheme( + { + palette: { + primary: { + main: blue[800], + lighter10: '#0D8CE91A', + }, + }, + components: { + MuiAppBar: { + styleOverrides: { + colorPrimary: { + backgroundColor: '#F6F8FA', + }, + }, + }, + }, + }, + enUS, +); + +export default theme; diff --git a/console/ui/src/shared/ui/copy-icon/assets/copyIcon.svg b/console/ui/src/shared/ui/copy-icon/assets/copyIcon.svg new file mode 100644 index 000000000..a45972213 --- /dev/null +++ b/console/ui/src/shared/ui/copy-icon/assets/copyIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/console/ui/src/shared/ui/copy-icon/index.ts b/console/ui/src/shared/ui/copy-icon/index.ts new file mode 100644 index 000000000..d870f7c87 --- /dev/null +++ b/console/ui/src/shared/ui/copy-icon/index.ts @@ -0,0 +1,3 @@ +import CopyIcon from '@shared/ui/copy-icon/ui'; + +export default CopyIcon; diff --git a/console/ui/src/shared/ui/copy-icon/model/types.ts b/console/ui/src/shared/ui/copy-icon/model/types.ts new file mode 100644 index 000000000..1cf47b13f --- /dev/null +++ b/console/ui/src/shared/ui/copy-icon/model/types.ts @@ -0,0 +1,3 @@ +export interface CopyIconProps { + valueToCopy?: string; +} diff --git a/console/ui/src/shared/ui/copy-icon/ui/index.tsx b/console/ui/src/shared/ui/copy-icon/ui/index.tsx new file mode 100644 index 000000000..6eb304990 --- /dev/null +++ b/console/ui/src/shared/ui/copy-icon/ui/index.tsx @@ -0,0 +1,17 @@ +import { FC } from 'react'; +import { CopyIconProps } from '@shared/ui/copy-icon/model/types.ts'; +import { useCopyToClipboard } from '@shared/lib/hooks.tsx'; +import CopyValueIcon from '@shared/ui/copy-icon/assets/copyIcon.svg?react'; +import { Box } from '@mui/material'; + +const CopyIcon: FC = ({ valueToCopy }) => { + const [_, copyFunction] = useCopyToClipboard(); + + return ( + copyFunction(valueToCopy)} sx={{ cursor: 'pointer' }}> + + + ); +}; + +export default CopyIcon; diff --git a/console/ui/src/shared/ui/default-form-buttons/index.ts b/console/ui/src/shared/ui/default-form-buttons/index.ts new file mode 100644 index 000000000..3d7cfb8e4 --- /dev/null +++ b/console/ui/src/shared/ui/default-form-buttons/index.ts @@ -0,0 +1,3 @@ +import DefaultFormButtons from '@shared/ui/default-form-buttons/ui'; + +export default DefaultFormButtons; diff --git a/console/ui/src/shared/ui/default-form-buttons/model/types.ts b/console/ui/src/shared/ui/default-form-buttons/model/types.ts new file mode 100644 index 000000000..fa08836ff --- /dev/null +++ b/console/ui/src/shared/ui/default-form-buttons/model/types.ts @@ -0,0 +1,10 @@ +import { ReactElement } from 'react'; + +export interface DefaultFormButtonsProps { + isDisabled?: boolean; + isSubmitting?: boolean; + submitButtonLabel?: string; + cancelButtonLabel?: string; + cancelHandler: () => void; + children?: ReactElement; +} diff --git a/console/ui/src/shared/ui/default-form-buttons/ui/index.tsx b/console/ui/src/shared/ui/default-form-buttons/ui/index.tsx new file mode 100644 index 000000000..93dbe5f77 --- /dev/null +++ b/console/ui/src/shared/ui/default-form-buttons/ui/index.tsx @@ -0,0 +1,51 @@ +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import styled from '@emotion/styled'; +import { LoadingButton } from '@mui/lab'; +import { DefaultFormButtonsProps } from '@shared/ui/default-form-buttons/model/types.ts'; +import { CircularProgress } from '@mui/material'; + +const StyledDefaultFormButtons = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; + +const StyledStandardButtons = styled.div` + display: grid; + grid-template: 1fr / repeat(2, auto); + grid-column-gap: 16px; + width: fit-content; +`; + +const DefaultFormButtons: FC = ({ + isDisabled = false, + isSubmitting = false, + cancelHandler, + submitButtonLabel, + cancelButtonLabel, + children, +}) => { + const { t } = useTranslation('shared'); + + return ( + + + } + type="submit"> + {submitButtonLabel ?? t('save')} + + + {cancelButtonLabel ?? t('cancel')} + + + {children} + + ); +}; + +export default DefaultFormButtons; diff --git a/console/ui/src/shared/ui/default-table/index.ts b/console/ui/src/shared/ui/default-table/index.ts new file mode 100644 index 000000000..a7eeb4844 --- /dev/null +++ b/console/ui/src/shared/ui/default-table/index.ts @@ -0,0 +1,3 @@ +import DefaultTable from '@shared/ui/default-table/ui'; + +export default DefaultTable; diff --git a/console/ui/src/shared/ui/default-table/ui/index.tsx b/console/ui/src/shared/ui/default-table/ui/index.tsx new file mode 100644 index 000000000..96de73e08 --- /dev/null +++ b/console/ui/src/shared/ui/default-table/ui/index.tsx @@ -0,0 +1,44 @@ +import { FC } from 'react'; +import { MaterialReactTable, MRT_RowData, MRT_TableOptions, useMaterialReactTable } from 'material-react-table'; +import { PAGINATION_LIMIT_OPTIONS } from '@shared/config/constants.ts'; +import { useTranslation } from 'react-i18next'; + +/** + * Common table with default styles. + * @param tableConfig - Object with additional table configuration. + * @constructor + */ +const DefaultTable: FC = ({ tableConfig }: { tableConfig: MRT_TableOptions }) => { + const { t } = useTranslation('shared'); + + const table = useMaterialReactTable({ + muiPaginationProps: { + rowsPerPageOptions: PAGINATION_LIMIT_OPTIONS, + }, + muiSearchTextFieldProps: { + placeholder: t('defaultTableSearchPlaceholder'), + sx: { minWidth: '300px' }, + }, + muiTableHeadCellProps: { + sx: { + backgroundColor: '#F6F8FA', + }, + }, + displayColumnDefOptions: { + 'mrt-row-select': { + visibleInShowHideMenu: false, + }, + 'mrt-row-actions': { + visibleInShowHideMenu: false, + }, + }, + layoutMode: 'grid', + positionActionsColumn: 'last', + positionGlobalFilter: 'left', + ...tableConfig, + }); + + return ; +}; + +export default DefaultTable; diff --git a/console/ui/src/shared/ui/info-card-body/index.ts b/console/ui/src/shared/ui/info-card-body/index.ts new file mode 100644 index 000000000..7a4ccea81 --- /dev/null +++ b/console/ui/src/shared/ui/info-card-body/index.ts @@ -0,0 +1,3 @@ +import InfoCardBody from '@shared/ui/info-card-body/ui'; + +export default InfoCardBody; diff --git a/console/ui/src/shared/ui/info-card-body/model/types.ts b/console/ui/src/shared/ui/info-card-body/model/types.ts new file mode 100644 index 000000000..6b9017cf5 --- /dev/null +++ b/console/ui/src/shared/ui/info-card-body/model/types.ts @@ -0,0 +1,8 @@ +import { ReactNode } from 'react'; + +export interface InfoCardBodyProps { + config: { + title: string; + children: ReactNode; + }[]; +} 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 new file mode 100644 index 000000000..b1b485437 --- /dev/null +++ b/console/ui/src/shared/ui/info-card-body/ui/index.tsx @@ -0,0 +1,27 @@ +import { FC } from 'react'; +import { Divider, Stack, Typography } from '@mui/material'; +import { InfoCardBodyProps } from '@shared/ui/info-card-body/model/types.ts'; + +/** + * Component renders body of a different summary and overview cards. + * Recommended to use inside all similar looking card bodies. + * @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} + + ))} + + ); +}; + +export default InfoCardBody; diff --git a/console/ui/src/shared/ui/selectable-box/index.ts b/console/ui/src/shared/ui/selectable-box/index.ts new file mode 100644 index 000000000..8c3b67b74 --- /dev/null +++ b/console/ui/src/shared/ui/selectable-box/index.ts @@ -0,0 +1,3 @@ +import SelectableBox from '@shared/ui/selectable-box/ui'; + +export default SelectableBox; diff --git a/console/ui/src/shared/ui/selectable-box/model/types.ts b/console/ui/src/shared/ui/selectable-box/model/types.ts new file mode 100644 index 000000000..fd5eb3171 --- /dev/null +++ b/console/ui/src/shared/ui/selectable-box/model/types.ts @@ -0,0 +1,8 @@ +import { SxProps } from '@mui/material'; +import { ReactNode } from 'react'; + +export interface ClusterFormSelectableBoxProps extends ReactNode { + children?: ReactNode; + isActive?: boolean; + sx?: SxProps; +} diff --git a/console/ui/src/shared/ui/selectable-box/ui/index.tsx b/console/ui/src/shared/ui/selectable-box/ui/index.tsx new file mode 100644 index 000000000..cd18565b6 --- /dev/null +++ b/console/ui/src/shared/ui/selectable-box/ui/index.tsx @@ -0,0 +1,21 @@ +import { FC } from 'react'; +import { ClusterFormSelectableBoxProps } from '@shared/ui/selectable-box/model/types.ts'; +import theme from '@shared/theme/theme.ts'; +import { Box } from '@mui/material'; + +const SelectableBox: FC = ({ isActive, children, sx, ...props }) => { + return ( + + {children} + + ); +}; + +export default SelectableBox; diff --git a/console/ui/src/shared/ui/settings-add-entity/model/constants.ts b/console/ui/src/shared/ui/settings-add-entity/model/constants.ts new file mode 100644 index 000000000..d95c1f9cc --- /dev/null +++ b/console/ui/src/shared/ui/settings-add-entity/model/constants.ts @@ -0,0 +1,4 @@ +export const ADD_ENTITY_FORM_NAMES = Object.freeze({ + NAME: 'name', + DESCRIPTION: 'description', +}); diff --git a/console/ui/src/shared/ui/settings-add-entity/model/types.ts b/console/ui/src/shared/ui/settings-add-entity/model/types.ts new file mode 100644 index 000000000..3eaf490cf --- /dev/null +++ b/console/ui/src/shared/ui/settings-add-entity/model/types.ts @@ -0,0 +1,13 @@ +import { ADD_ENTITY_FORM_NAMES } from '@shared/ui/settings-add-entity/model/constants.ts'; + +export interface AddEntityFormValues { + [ADD_ENTITY_FORM_NAMES.NAME]: string; + [ADD_ENTITY_FORM_NAMES.NAME]: string; +} + +export interface SettingsAddEntityProps { + buttonLabel: string; + submitButtonLabel: string; + isLoading?: boolean; + submitTrigger: (values: AddEntityFormValues) => void; +} diff --git a/console/ui/src/shared/ui/settings-add-entity/model/validation.ts b/console/ui/src/shared/ui/settings-add-entity/model/validation.ts new file mode 100644 index 000000000..f78a86fc7 --- /dev/null +++ b/console/ui/src/shared/ui/settings-add-entity/model/validation.ts @@ -0,0 +1,9 @@ +import { TFunction } from 'i18next'; +import * as yup from 'yup'; +import { ADD_ENTITY_FORM_NAMES } from '@shared/ui/settings-add-entity/model/constants.ts'; + +export const AddEntityFormSchema = (t: TFunction) => + yup.object({ + [ADD_ENTITY_FORM_NAMES.NAME]: yup.string().required(t('requiredField', { ns: 'validation' })), + [ADD_ENTITY_FORM_NAMES.DESCRIPTION]: yup.string(), + }); diff --git a/console/ui/src/shared/ui/settings-add-entity/ui/index.tsx b/console/ui/src/shared/ui/settings-add-entity/ui/index.tsx new file mode 100644 index 000000000..0a3c12713 --- /dev/null +++ b/console/ui/src/shared/ui/settings-add-entity/ui/index.tsx @@ -0,0 +1,120 @@ +import React, { FC, useState } from 'react'; +import { Button, Card, CircularProgress, Modal, Stack, TextField, Typography } from '@mui/material'; +import { Controller, useForm } from 'react-hook-form'; +import { LoadingButton } from '@mui/lab'; +import AddBoxOutlinedIcon from '@mui/icons-material/AddBoxOutlined'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { AddEntityFormValues, SettingsAddEntityProps } from '@shared/ui/settings-add-entity/model/types.ts'; +import { AddEntityFormSchema } from '@shared/ui/settings-add-entity/model/validation.ts'; +import { ADD_ENTITY_FORM_NAMES } from '@shared/ui/settings-add-entity/model/constants.ts'; +import { handleRequestErrorCatch } from '@shared/lib/functions.ts'; +import { useTranslation } from 'react-i18next'; + +const SettingsAddEntity: FC = ({ + buttonLabel, + headerLabel, + submitButtonLabel, + nameLabel, + isLoading, + submitTrigger, +}) => { + const { t } = useTranslation(['settings', 'shared']); + const [isModalOpen, setIsModalOpen] = useState(false); + + const handleModalOpenState = (isOpen: boolean) => () => setIsModalOpen(isOpen); + + const { + control, + handleSubmit, + formState: { errors, isValid, isSubmitting }, + } = useForm({ + mode: 'all', + resolver: yupResolver(AddEntityFormSchema(t)), + }); + + const onSubmit = async (values: AddEntityFormValues) => { + try { + await submitTrigger(values); + setIsModalOpen(false); + } catch (e) { + handleRequestErrorCatch(e); + } + }; + + return ( + <> + + +
+ + + + {headerLabel ?? t('add', { ns: 'shared' })} + + + ( + + )} + /> + + + ( + + )} + /> + + } + loading={isSubmitting || isLoading}> + {submitButtonLabel ?? t('add')} + + + +
+
+ + ); +}; + +export default SettingsAddEntity; diff --git a/console/ui/src/shared/ui/slider-box/index.ts b/console/ui/src/shared/ui/slider-box/index.ts new file mode 100644 index 000000000..245befdf6 --- /dev/null +++ b/console/ui/src/shared/ui/slider-box/index.ts @@ -0,0 +1,3 @@ +import ClusterSliderBox from '@shared/ui/slider-box/ui'; + +export default ClusterSliderBox; diff --git a/console/ui/src/shared/ui/slider-box/lib/functions.ts b/console/ui/src/shared/ui/slider-box/lib/functions.ts new file mode 100644 index 000000000..3f9bb095e --- /dev/null +++ b/console/ui/src/shared/ui/slider-box/lib/functions.ts @@ -0,0 +1,17 @@ +import { GenerateMarkType, GenerateSliderMarksType } from '@shared/ui/slider-box/model/types.ts'; + +const generateMark: GenerateMarkType = (value, marksAdditionalLabel) => ({ + value, + label: `${value} ${marksAdditionalLabel}`, +}); + +export const generateSliderMarks: GenerateSliderMarksType = (min, max, amount, marksAdditionalLabel) => { + const step = (max - min) / (amount - 1); + const marksArray = []; + + for (let i = min; i < max + step; i += step) { + marksArray.push(generateMark(Math.trunc(i), marksAdditionalLabel)); + } + + return marksArray; +}; diff --git a/console/ui/src/shared/ui/slider-box/model/types.ts b/console/ui/src/shared/ui/slider-box/model/types.ts new file mode 100644 index 000000000..d8af2950e --- /dev/null +++ b/console/ui/src/shared/ui/slider-box/model/types.ts @@ -0,0 +1,29 @@ +import { ReactElement } from 'react'; + +export interface SliderBoxProps { + amount: number; + changeAmount: (...event: any[]) => void; + icon?: ReactElement; + unit?: string; + min: number; + max: number; + marks?: { label: unknown; value: unknown }[]; + marksAmount?: number; + marksAdditionalLabel?: string; + step?: number | null; + error?: object; + limitMin?: boolean; + limitMax?: boolean; +} + +export type GenerateMarkType = (value: number, marksAdditionalLabel: string) => { label: string; value: string }; + +export type GenerateSliderMarksType = ( + min: number, + max: number, + amount: number, + marksAdditionalLabel: string, +) => { + label: string; + value: string; +}[]; diff --git a/console/ui/src/shared/ui/slider-box/ui/index.tsx b/console/ui/src/shared/ui/slider-box/ui/index.tsx new file mode 100644 index 000000000..15bbb190b --- /dev/null +++ b/console/ui/src/shared/ui/slider-box/ui/index.tsx @@ -0,0 +1,67 @@ +import { FC } from 'react'; +import { Box, Slider, TextField, Typography } from '@mui/material'; +import { SliderBoxProps } from '@shared/ui/slider-box/model/types.ts'; + +import { generateSliderMarks } from '@shared/ui/slider-box/lib/functions.ts'; + +const ClusterSliderBox: FC = ({ + amount, + changeAmount, + unit, + icon, + min = 1, + max, + marks, + marksAmount, + marksAdditionalLabel = '', + step, + error, + limitMin = true, + limitMax, +}) => { + const onChange = (e) => { + const { value } = e.target; + + if (/^\d*$/.test(value)) changeAmount(value < min && limitMin ? min : value > max && limitMax ? max : value); + }; + + return ( + + + {icon} + + {unit} + + + + + + ); +}; + +export default ClusterSliderBox; diff --git a/console/ui/src/shared/ui/spinner/index.ts b/console/ui/src/shared/ui/spinner/index.ts new file mode 100644 index 000000000..14e55f442 --- /dev/null +++ b/console/ui/src/shared/ui/spinner/index.ts @@ -0,0 +1,3 @@ +import Spinner from '@shared/ui/spinner/ui'; + +export default Spinner; diff --git a/console/ui/src/shared/ui/spinner/ui/index.tsx b/console/ui/src/shared/ui/spinner/ui/index.tsx new file mode 100644 index 000000000..2e61ac1b4 --- /dev/null +++ b/console/ui/src/shared/ui/spinner/ui/index.tsx @@ -0,0 +1,12 @@ +import { FC } from 'react'; +import { CircularProgress, Stack } from '@mui/material'; + +const Spinner: FC = () => { + return ( + + + + ); +}; + +export default Spinner; diff --git a/console/ui/src/widgets/cluster-form/index.ts b/console/ui/src/widgets/cluster-form/index.ts new file mode 100644 index 000000000..24ad7a10d --- /dev/null +++ b/console/ui/src/widgets/cluster-form/index.ts @@ -0,0 +1,3 @@ +import ClusterForm from '@widgets/cluster-form/ui'; + +export default ClusterForm; diff --git a/console/ui/src/widgets/cluster-form/model/constants.ts b/console/ui/src/widgets/cluster-form/model/constants.ts new file mode 100644 index 000000000..3aeb2d1f0 --- /dev/null +++ b/console/ui/src/widgets/cluster-form/model/constants.ts @@ -0,0 +1,36 @@ +export const numberOfInstances = [1, 3, 7, 15, 32]; +export const dataDiskStorage = [10, 100, 500, 1000, 2000, 16000]; + +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', +}); + +const CLUSTER_LOCAL_MACHINE_FIELD_NAMES = Object.freeze({ + DATABASE_SERVERS: 'databaseServers', + 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', +}); + +export const CLUSTER_FORM_FIELD_NAMES = Object.freeze({ + PROVIDER: 'provider', + ENVIRONMENT_ID: 'environment', + CLUSTER_NAME: 'clusterName', + DESCRIPTION: 'description', + POSTGRES_VERSION: 'postgresVersion', + SECRET_ID: 'secretId', + ...CLUSTER_CLOUD_PROVIDER_FIELD_NAMES, + ...CLUSTER_LOCAL_MACHINE_FIELD_NAMES, +}); diff --git a/console/ui/src/widgets/cluster-form/model/types.ts b/console/ui/src/widgets/cluster-form/model/types.ts new file mode 100644 index 000000000..f0f9afaa3 --- /dev/null +++ b/console/ui/src/widgets/cluster-form/model/types.ts @@ -0,0 +1,13 @@ +import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; + +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 new file mode 100644 index 000000000..4c7782d5e --- /dev/null +++ b/console/ui/src/widgets/cluster-form/model/validation.ts @@ -0,0 +1,203 @@ +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 ipRegex from 'ip-regex'; +import { SECRET_MODAL_CONTENT_FORM_FIELD_NAMES } from '@entities/secret-form-block/model/constants.ts'; + +const cloudFormSchema = (t: TFunction) => + yup.object({ + [CLUSTER_FORM_FIELD_NAMES.REGION]: yup + .mixed() + .when(CLUSTER_FORM_FIELD_NAMES.PROVIDER, ([provider], schema) => + provider?.code !== PROVIDERS.LOCAL ? yup.string().required() : schema.notRequired(), + ), + [CLUSTER_FORM_FIELD_NAMES.REGION_CONFIG]: yup + .mixed() + .when(CLUSTER_FORM_FIELD_NAMES.PROVIDER, ([provider], schema) => + provider?.code !== PROVIDERS.LOCAL + ? yup + .object({ + code: yup.string(), + location: yup.string(), + }) + .required() + : schema.notRequired(), + ), + [CLUSTER_FORM_FIELD_NAMES.INSTANCE_TYPE]: yup + .mixed() + .when(CLUSTER_FORM_FIELD_NAMES.PROVIDER, ([provider], schema) => + provider?.code !== PROVIDERS.LOCAL ? yup.string().required() : schema.notRequired(), + ), + [CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]: yup + .mixed() + .when(CLUSTER_FORM_FIELD_NAMES.PROVIDER, ([provider], schema) => + provider?.code !== PROVIDERS.LOCAL + ? yup + .object({ + code: yup.string(), + cpu: yup.number(), + currency: yup.string(), + price_hourly: yup.number(), + price_monthly: yup.number(), + ram: yup.number(), + }) + .required() + : schema.notRequired(), + ), + [CLUSTER_FORM_FIELD_NAMES.INSTANCES_AMOUNT]: yup + .mixed() + .when(CLUSTER_FORM_FIELD_NAMES.PROVIDER, ([provider], schema) => + provider?.code !== PROVIDERS.LOCAL ? yup.number().required() : schema.notRequired(), + ), + [CLUSTER_FORM_FIELD_NAMES.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 + .mixed() + .when(CLUSTER_FORM_FIELD_NAMES.PROVIDER, ([provider], schema) => + provider?.code !== PROVIDERS.LOCAL + ? yup.string().required(t('requiredField', { ns: 'validation' })) + : schema.notRequired(), + ), + }); + +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 + ? 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 + .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(), + ), + }); + +export const ClusterFormSchema = (t: TFunction) => + yup + .object({ + [CLUSTER_FORM_FIELD_NAMES.PROVIDER]: yup.object().required(), + [CLUSTER_FORM_FIELD_NAMES.ENVIRONMENT_ID]: yup.number(), + [CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME]: yup + .string() + .test('clusters should have proper naming', t('clusterShouldHaveProperNaming', { ns: 'validation' }), (value) => + value.match(/^[a-z0-9][a-z0-9-]{0,23}$/g), + ) + .required(t('requiredField', { ns: 'validation' })), + [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)); diff --git a/console/ui/src/widgets/cluster-form/ui/ClusterFormCloudProviderFormPart.tsx b/console/ui/src/widgets/cluster-form/ui/ClusterFormCloudProviderFormPart.tsx new file mode 100644 index 000000000..32fe3e026 --- /dev/null +++ b/console/ui/src/widgets/cluster-form/ui/ClusterFormCloudProviderFormPart.tsx @@ -0,0 +1,18 @@ +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'; + +const ClusterFormCloudProviderFormPart: FC = () => ( + <> + + + + + + +); + +export default ClusterFormCloudProviderFormPart; diff --git a/console/ui/src/widgets/cluster-form/ui/ClusterFormLocalMachineFormPart.tsx b/console/ui/src/widgets/cluster-form/ui/ClusterFormLocalMachineFormPart.tsx new file mode 100644 index 000000000..5ae5dcc61 --- /dev/null +++ b/console/ui/src/widgets/cluster-form/ui/ClusterFormLocalMachineFormPart.tsx @@ -0,0 +1,16 @@ +import { FC } from 'react'; +import DatabaseServersBlock from '@entities/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'; + +const ClusterFormLocalMachineFormPart: FC = () => ( + <> + + + + + +); + +export default ClusterFormLocalMachineFormPart; diff --git a/console/ui/src/widgets/cluster-form/ui/ClusterFormRegionConfigBox.tsx b/console/ui/src/widgets/cluster-form/ui/ClusterFormRegionConfigBox.tsx new file mode 100644 index 000000000..2dd7cdfd5 --- /dev/null +++ b/console/ui/src/widgets/cluster-form/ui/ClusterFormRegionConfigBox.tsx @@ -0,0 +1,22 @@ +import { FC } from 'react'; +import SelectableBox from '@shared/ui/selectable-box'; +import { Box, Typography } from '@mui/material'; +import FlagsIcon from '@assets/flagIcon.svg?react'; +import { ClusterFormRegionConfigBoxProps } from '@widgets/cluster-form/model/types.ts'; + +const ClusterFormRegionConfigBox: FC = ({ name, place, isActive, ...props }) => { + return ( + + + {name} + + + +   + {place} + + + ); +}; + +export default ClusterFormRegionConfigBox; diff --git a/console/ui/src/widgets/cluster-form/ui/index.tsx b/console/ui/src/widgets/cluster-form/ui/index.tsx new file mode 100644 index 000000000..ee8cdb662 --- /dev/null +++ b/console/ui/src/widgets/cluster-form/ui/index.tsx @@ -0,0 +1,210 @@ +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 DefaultFormButtons from '@shared/ui/default-form-buttons'; +import { FormProvider, useForm } 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 { 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 { 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 = () => { + 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 watchProvider = methods.watch(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({ + requestSecretCreate: { + project_id: Number(currentProject), + type: values[CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD], + name: values[CLUSTER_FORM_FIELD_NAMES.SECRET_KEY_NAME], + value: getSecretBodyFromValues({ + ...values, + [SECRET_MODAL_CONTENT_FORM_FIELD_NAMES.SECRET_TYPE]: values[CLUSTER_FORM_FIELD_NAMES.AUTHENTICATION_METHOD], + }), + }, + }).unwrap(); + toast.success( + t('secretSuccessfullyCreated', { + ns: 'toasts', + secretName: values[CLUSTER_FORM_FIELD_NAMES.SECRET_KEY_NAME], + }), + ); + } + await addClusterTrigger({ + requestClusterCreate: mapFormValuesToRequestFields({ + values, + secretId: createSecretResultRef.current?.id ?? Number(values[CLUSTER_FORM_FIELD_NAMES.SECRET_ID]), + projectId: Number(currentProject), + }), + }).unwrap(); + toast.info( + t('clusterSuccessfullyCreated', { + ns: 'toasts', + clusterName: values[CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME], + }), + ); + }; + + const submitCloudCluster = async (values: ClusterFormValues) => { + await addClusterTrigger({ + requestClusterCreate: mapFormValuesToRequestFields({ + values, + secretId: secrets?.data?.data?.[0]?.id, + projectId: Number(currentProject), + }), + }).unwrap(); + toast.info( + t('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)); + } catch (e) { + handleRequestErrorCatch(e); + } + }; + + 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 ? ( + + ) : ( + + )} + +
+ +
+
+
+ ); +}; + +export default ClusterForm; diff --git a/console/ui/src/widgets/cluster-overview-table/index.ts b/console/ui/src/widgets/cluster-overview-table/index.ts new file mode 100644 index 000000000..80eaaa422 --- /dev/null +++ b/console/ui/src/widgets/cluster-overview-table/index.ts @@ -0,0 +1,3 @@ +import ClusterOverviewTable from '@widgets/cluster-overview-table/ui'; + +export default ClusterOverviewTable; diff --git a/console/ui/src/widgets/cluster-overview-table/lib/hooks.tsx b/console/ui/src/widgets/cluster-overview-table/lib/hooks.tsx new file mode 100644 index 000000000..55557b8d8 --- /dev/null +++ b/console/ui/src/widgets/cluster-overview-table/lib/hooks.tsx @@ -0,0 +1,28 @@ +import { useMemo } from 'react'; +import { CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES } from '@widgets/cluster-overview-table/model/constants.ts'; +import { ClusterInfoInstance } from '@shared/api/api/clusters.ts'; +import { Box, Chip } from '@mui/material'; + +export const useGetOverviewClusterTableData = (data: ClusterInfoInstance[]) => { + return useMemo( + () => + data?.map((item) => ({ + [CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.NAME]: item?.name, + [CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.HOST]: item?.ip, + [CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.ROLE]: item?.role, + [CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.STATE]: item?.status, + [CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.TIMELINE]: item?.timeline, + [CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.LAG_IN_MB]: item?.lag, + [CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.TAGS]: item?.tags && ( + + {Object.entries(item.tags).map(([key, value]) => ( + + ))} + + ), + [CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.PENDING_RESTART]: String(item?.pending_restart), + [CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.ID]: item?.id, + })) ?? [], + [data], + ); +}; diff --git a/console/ui/src/widgets/cluster-overview-table/model/constants.ts b/console/ui/src/widgets/cluster-overview-table/model/constants.ts new file mode 100644 index 000000000..ae6fa0719 --- /dev/null +++ b/console/ui/src/widgets/cluster-overview-table/model/constants.ts @@ -0,0 +1,49 @@ +import { createMRTColumnHelper } from 'material-react-table'; +import { TFunction } from 'i18next'; +import { ClusterOverviewTableValues } from '@widgets/cluster-overview-table/model/types.ts'; + +export const CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES = Object.freeze({ + NAME: 'name', + HOST: 'host', + ROLE: 'role', + STATE: 'state', + TIMELINE: 'timeline', + LAG_IN_MB: 'lagInMb', + PENDING_RESTART: 'pendingRestart', + TAGS: 'tags', + ID: 'id', +}); + +const columnHelper = createMRTColumnHelper(); + +export const clusterOverviewTableColumns = (t: TFunction) => [ + columnHelper.accessor(CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.NAME, { + header: t('name', { ns: 'shared' }), + }), + columnHelper.accessor(CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.HOST, { + header: t('host', { ns: 'clusters' }), + size: 70, + }), + columnHelper.accessor(CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.ROLE, { + header: t('role', { ns: 'clusters' }), + size: 120, + }), + columnHelper.accessor(CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.STATE, { + header: t('state', { ns: 'clusters' }), + size: 110, + }), + columnHelper.accessor(CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.TIMELINE, { + header: t('timeline', { ns: 'clusters' }), + size: 80, + }), + columnHelper.accessor(CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.LAG_IN_MB, { + header: t('lagInMb', { ns: 'clusters' }), + size: 140, + }), + columnHelper.accessor(CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.PENDING_RESTART, { + header: t('pendingRestart', { ns: 'clusters' }), + }), + columnHelper.accessor(CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.TAGS, { + header: t('tags', { ns: 'clusters' }), + }), +]; diff --git a/console/ui/src/widgets/cluster-overview-table/model/types.ts b/console/ui/src/widgets/cluster-overview-table/model/types.ts new file mode 100644 index 000000000..4e080da08 --- /dev/null +++ b/console/ui/src/widgets/cluster-overview-table/model/types.ts @@ -0,0 +1,19 @@ +import { CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES } from '@widgets/cluster-overview-table/model/constants.ts'; +import { ClusterInfoInstance } from '@shared/api/api/clusters.ts'; + +export interface ClusterOverviewTableProps { + clusterName?: string; + items?: ClusterInfoInstance[]; + isLoading?: boolean; +} + +export interface ClusterOverviewTableValues { + [CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.NAME]: string; + [CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.HOST]: string; + [CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.ROLE]: string; + [CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.STATE]: string; + [CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.TIMELINE]: number; + [CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.LAG_IN_MB]: number; + [CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.PENDING_RESTART]: string; + [CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.TAGS]: string; +} diff --git a/console/ui/src/widgets/cluster-overview-table/ui/ClustersOverviewTableButtons.tsx b/console/ui/src/widgets/cluster-overview-table/ui/ClustersOverviewTableButtons.tsx new file mode 100644 index 000000000..ea404c556 --- /dev/null +++ b/console/ui/src/widgets/cluster-overview-table/ui/ClustersOverviewTableButtons.tsx @@ -0,0 +1,27 @@ +import { FC } from 'react'; +import { Button, Stack } from '@mui/material'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import { useTranslation } from 'react-i18next'; +import { usePostClustersByIdRefreshMutation } from '@shared/api/api/clusters.ts'; +import { useParams } from 'react-router-dom'; + +const ClustersOverviewTableButtons: FC = () => { + const { t } = useTranslation('shared'); + const { clusterId } = useParams(); + + const [refreshClusterTrigger] = usePostClustersByIdRefreshMutation(); + + const handleRefresh = async () => { + await refreshClusterTrigger({ id: Number(clusterId) }); + }; + + return ( + + + + ); +}; + +export default ClustersOverviewTableButtons; diff --git a/console/ui/src/widgets/cluster-overview-table/ui/index.tsx b/console/ui/src/widgets/cluster-overview-table/ui/index.tsx new file mode 100644 index 000000000..1af8b8489 --- /dev/null +++ b/console/ui/src/widgets/cluster-overview-table/ui/index.tsx @@ -0,0 +1,56 @@ +import { FC, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { MRT_ColumnDef, MRT_RowData, MRT_TableOptions } from 'material-react-table'; +import { ClusterOverviewTableProps } from '@widgets/cluster-overview-table/model/types.ts'; +import { + CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES, + clusterOverviewTableColumns, +} from '@widgets/cluster-overview-table/model/constants.ts'; +import ClustersOverviewTableButtons from '@widgets/cluster-overview-table/ui/ClustersOverviewTableButtons.tsx'; +import { useGetOverviewClusterTableData } from '@widgets/cluster-overview-table/lib/hooks.tsx'; +import { ClusterInfo } from '@shared/api/api/clusters.ts'; +import DefaultTable from '@shared/ui/default-table'; +import { Stack, Typography } from '@mui/material'; +import ClustersOverviewTableRowActions from '@features/clusters-overview-table-row-actions'; + +const ClusterOverviewTable: FC = ({ clusterName = '', items, isLoading }) => { + const { t, i18n } = useTranslation('clusters'); + + const columns = useMemo[]>(() => clusterOverviewTableColumns(t), [i18n.language]); + + const data = useGetOverviewClusterTableData(items); + + const tableConfig: MRT_TableOptions = { + columns, + data, + enablePagination: false, + enableRowActions: true, + showGlobalFilter: false, + state: { + isLoading, + }, + initialState: { + columnVisibility: { + [CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.PENDING_RESTART]: false, + [CLUSTER_OVERVIEW_TABLE_COLUMN_NAMES.TAGS]: false, + }, + }, + renderRowActionMenuItems: ({ row, closeMenu }) => ( + + ), + }; + + return ( + <> + + + {t('cluster')}: {clusterName} + + + + + + ); +}; + +export default ClusterOverviewTable; diff --git a/console/ui/src/widgets/cluster-summary/assets/awsIcon.svg b/console/ui/src/widgets/cluster-summary/assets/awsIcon.svg new file mode 100644 index 000000000..3dec8e73f --- /dev/null +++ b/console/ui/src/widgets/cluster-summary/assets/awsIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/console/ui/src/widgets/cluster-summary/assets/azureIcon.svg b/console/ui/src/widgets/cluster-summary/assets/azureIcon.svg new file mode 100644 index 000000000..e24e2189c --- /dev/null +++ b/console/ui/src/widgets/cluster-summary/assets/azureIcon.svg @@ -0,0 +1,27 @@ + + + Azure + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/console/ui/src/widgets/cluster-summary/assets/digitaloceanIcon.svg b/console/ui/src/widgets/cluster-summary/assets/digitaloceanIcon.svg new file mode 100644 index 000000000..5a81f2481 --- /dev/null +++ b/console/ui/src/widgets/cluster-summary/assets/digitaloceanIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/console/ui/src/widgets/cluster-summary/assets/gcpIcon.svg b/console/ui/src/widgets/cluster-summary/assets/gcpIcon.svg new file mode 100644 index 000000000..92da9d7cc --- /dev/null +++ b/console/ui/src/widgets/cluster-summary/assets/gcpIcon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/console/ui/src/widgets/cluster-summary/assets/hetznerIcon.svg b/console/ui/src/widgets/cluster-summary/assets/hetznerIcon.svg new file mode 100644 index 000000000..73b15fff7 --- /dev/null +++ b/console/ui/src/widgets/cluster-summary/assets/hetznerIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/console/ui/src/widgets/cluster-summary/assets/hetznerIcon2.svg b/console/ui/src/widgets/cluster-summary/assets/hetznerIcon2.svg new file mode 100644 index 000000000..c8c8ad682 --- /dev/null +++ b/console/ui/src/widgets/cluster-summary/assets/hetznerIcon2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/console/ui/src/widgets/cluster-summary/index.ts b/console/ui/src/widgets/cluster-summary/index.ts new file mode 100644 index 000000000..1c5f9406f --- /dev/null +++ b/console/ui/src/widgets/cluster-summary/index.ts @@ -0,0 +1,3 @@ +import ClusterSummary from '@widgets/cluster-summary/ui'; + +export default ClusterSummary; diff --git a/console/ui/src/widgets/cluster-summary/lib/hooks.tsx b/console/ui/src/widgets/cluster-summary/lib/hooks.tsx new file mode 100644 index 000000000..9ecacc2c8 --- /dev/null +++ b/console/ui/src/widgets/cluster-summary/lib/hooks.tsx @@ -0,0 +1,205 @@ +import { Icon, Link, Stack, Typography } from '@mui/material'; +import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; +import { Trans, useTranslation } from 'react-i18next'; +import { providerNamePricingListMap } from '@widgets/cluster-summary/model/constants.ts'; +import RamIcon from '@shared/assets/ramIcon.svg?react'; +import InstanceIcon from '@shared/assets/instanceIcon.svg?react'; +import StorageIcon from '@shared/assets/storageIcon.svg?react'; +import LanIcon from '@shared/assets/lanIcon.svg?react'; +import FlagIcon from '@shared/assets/flagIcon.svg?react'; +import CheckIcon from '@shared/assets/checkIcon.svg?react'; +import CpuIcon from '@shared/assets/cpuIcon.svg?react'; +import WarningAmberOutlinedIcon from '@mui/icons-material/WarningAmberOutlined'; +import { + CloudProviderClustersSummary, + LocalClustersSummary, + UseGetSummaryConfigProps, +} from '@widgets/cluster-summary/model/types.ts'; + +const useGetCloudProviderConfig = () => { + const { t } = useTranslation(['clusters', 'shared']); + + return (data: CloudProviderClustersSummary) => { + const defaultVolume = data[CLUSTER_FORM_FIELD_NAMES.PROVIDER]?.volumes?.find((volume) => volume?.is_default) ?? {}; + + return [ + { + title: t('name'), + children: {data[CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME]}, + }, + { + title: t('postgresVersion'), + children: {data[CLUSTER_FORM_FIELD_NAMES.POSTGRES_VERSION]}, + }, + { + title: t('cloud'), + children: ( + + + {data[CLUSTER_FORM_FIELD_NAMES.PROVIDER]?.description?.[0]} + + {data[CLUSTER_FORM_FIELD_NAMES.PROVIDER]?.description} + + ), + }, + { + title: t('region'), + children: ( + + {data[CLUSTER_FORM_FIELD_NAMES.REGION_CONFIG]?.code} + + + {data[CLUSTER_FORM_FIELD_NAMES.REGION_CONFIG]?.location} + + + ), + }, + { + 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} RAM + + + + ), + }, + { + title: t('numberOfInstances'), + children: ( + + + {data[CLUSTER_FORM_FIELD_NAMES.INSTANCES_AMOUNT]} + + ), + }, + { + title: t('dataDiskStorage'), + children: ( + + + {data[CLUSTER_FORM_FIELD_NAMES.STORAGE_AMOUNT]} GB + + ), + }, + { + 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' })}`} + + + + + + + + + ), + }, + ]; + }; +}; + +const useGetLocalMachineConfig = () => { + const { t } = useTranslation(['clusters', 'shared']); + + return (data: LocalClustersSummary) => [ + { + title: t('name'), + children: {data[CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME]}, + }, + { + title: t('postgresVersion'), + children: {data[CLUSTER_FORM_FIELD_NAMES.POSTGRES_VERSION]}, + }, + { + title: t('numberOfServers'), + children: ( + + + {data[CLUSTER_FORM_FIELD_NAMES.DATABASE_SERVERS]?.length} + + ), + }, + { + title: t('loadBalancing'), + children: ( + + + + {data[CLUSTER_FORM_FIELD_NAMES.IS_HAPROXY_LOAD_BALANCER] + ? t('on', { ns: 'shared' }) + : t('off', { ns: 'shared' })} + + + ), + }, + { + title: t('highAvailability'), + children: ( + + + {data[CLUSTER_FORM_FIELD_NAMES.DATABASE_SERVERS]?.length >= 3 ? ( + + ) : ( + + )} + + {data[CLUSTER_FORM_FIELD_NAMES.DATABASE_SERVERS]?.length >= 3 + ? t('on', { ns: 'shared' }) + : t('off', { ns: 'shared' })} + + + + {t('highAvailabilityInfo')} + + + ), + }, + ]; +}; + +export const useGetSummaryConfig = ({ isCloudProvider, data }: UseGetSummaryConfigProps) => { + const cloudProviderConfig = useGetCloudProviderConfig(); + const localProviderConfig = useGetLocalMachineConfig(); + + return isCloudProvider + ? cloudProviderConfig(data as CloudProviderClustersSummary) + : localProviderConfig(data as LocalClustersSummary); +}; diff --git a/console/ui/src/widgets/cluster-summary/model/constants.ts b/console/ui/src/widgets/cluster-summary/model/constants.ts new file mode 100644 index 000000000..e69114190 --- /dev/null +++ b/console/ui/src/widgets/cluster-summary/model/constants.ts @@ -0,0 +1,22 @@ +import { PROVIDERS } from '@shared/config/constants.ts'; +import AWSIcon from '@widgets/cluster-summary/assets/awsIcon.svg'; +import GCPIcon from '@widgets/cluster-summary/assets/gcpIcon.svg'; +import AzureIcon from '@widgets/cluster-summary/assets/azureIcon.svg'; +import DigitalOceanIcon from '@widgets/cluster-summary/assets/digitaloceanIcon.svg'; +import HetznerIcon from '@widgets/cluster-summary/assets/hetznerIcon2.svg'; + +export const providerNamePricingListMap = Object.freeze({ + [PROVIDERS.AWS]: 'https://aws.amazon.com/ec2/pricing/on-demand/', + [PROVIDERS.GCP]: 'https://cloud.google.com/compute/vm-instance-pricing/#general-purpose_machine_type_family', + [PROVIDERS.AZURE]: 'https://azure.microsoft.com/en-us/pricing/details/virtual-machines/linux/', + [PROVIDERS.DIGITAL_OCEAN]: 'https://www.digitalocean.com/pricing/droplets/', + [PROVIDERS.HETZNER]: 'https://www.hetzner.com/cloud/', +}); + +export const clusterSummaryNameIconProvidersMap = Object.freeze({ + [PROVIDERS.AWS]: AWSIcon, + [PROVIDERS.GCP]: GCPIcon, + [PROVIDERS.AZURE]: AzureIcon, + [PROVIDERS.DIGITAL_OCEAN]: DigitalOceanIcon, + [PROVIDERS.HETZNER]: HetznerIcon, +}); diff --git a/console/ui/src/widgets/cluster-summary/model/types.ts b/console/ui/src/widgets/cluster-summary/model/types.ts new file mode 100644 index 000000000..dfb8a898f --- /dev/null +++ b/console/ui/src/widgets/cluster-summary/model/types.ts @@ -0,0 +1,37 @@ +import { ReactElement } from 'react'; +import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; + +export interface SharedClusterSummaryProps { + [CLUSTER_FORM_FIELD_NAMES.CLUSTER_NAME]: string; + [CLUSTER_FORM_FIELD_NAMES.POSTGRES_VERSION]: number; +} + +export interface CloudProviderClustersSummary extends SharedClusterSummaryProps { + [CLUSTER_FORM_FIELD_NAMES.PROVIDER]: { + icon: ReactElement; + name: string; + }; + [CLUSTER_FORM_FIELD_NAMES.REGION_CONFIG]: { + name: string; + place: string; + }; + [CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]: { + name: string; + cpu: number; + ram: number; + }; + [CLUSTER_FORM_FIELD_NAMES.INSTANCES_AMOUNT]: number; + [CLUSTER_FORM_FIELD_NAMES.STORAGE_AMOUNT]: number; + [CLUSTER_FORM_FIELD_NAMES.INSTANCE_CONFIG]: number; + [CLUSTER_FORM_FIELD_NAMES.INSTANCES_AMOUNT]: number; +} + +export interface LocalClustersSummary extends SharedClusterSummaryProps { + [CLUSTER_FORM_FIELD_NAMES.DATABASE_SERVERS]: number; + [CLUSTER_FORM_FIELD_NAMES.IS_HAPROXY_LOAD_BALANCER]: boolean; +} + +export interface UseGetSummaryConfigProps { + isCloudProvider: boolean; + data: CloudProviderClustersSummary | LocalClustersSummary; +} diff --git a/console/ui/src/widgets/cluster-summary/ui/index.tsx b/console/ui/src/widgets/cluster-summary/ui/index.tsx new file mode 100644 index 000000000..06d83508b --- /dev/null +++ b/console/ui/src/widgets/cluster-summary/ui/index.tsx @@ -0,0 +1,50 @@ +import { FC } from 'react'; +import { Divider, Paper, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { CLUSTER_FORM_FIELD_NAMES } from '@widgets/cluster-form/model/constants.ts'; +import { useFormContext, useWatch } from 'react-hook-form'; +import { useGetSummaryConfig } from '@widgets/cluster-summary/lib/hooks.tsx'; +import { PROVIDERS } from '@shared/config/constants.ts'; +import InfoCardBody from '@shared/ui/info-card-body'; +import { clusterSummaryNameIconProvidersMap } from '@widgets/cluster-summary/model/constants.ts'; + +const ClusterSummary: FC = () => { + const { t } = useTranslation(['clusters', 'shared']); + const { control } = useFormContext(); + + const watchValues = useWatch({ + control, + }); + + const config = useGetSummaryConfig({ + isCloudProvider: watchValues[CLUSTER_FORM_FIELD_NAMES.PROVIDER]?.code !== PROVIDERS.LOCAL, + data: { + ...watchValues, + [CLUSTER_FORM_FIELD_NAMES.PROVIDER]: { + ...watchValues[CLUSTER_FORM_FIELD_NAMES.PROVIDER], + icon: clusterSummaryNameIconProvidersMap[watchValues[CLUSTER_FORM_FIELD_NAMES.PROVIDER]?.code], + }, + }, + }); + + return ( + + + {t('summary')} + + + + + ); +}; + +export default ClusterSummary; diff --git a/console/ui/src/widgets/clusters-table/assets/correctIcon.svg b/console/ui/src/widgets/clusters-table/assets/correctIcon.svg new file mode 100644 index 000000000..17486953f --- /dev/null +++ b/console/ui/src/widgets/clusters-table/assets/correctIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/console/ui/src/widgets/clusters-table/assets/errorIcon.svg b/console/ui/src/widgets/clusters-table/assets/errorIcon.svg new file mode 100644 index 000000000..74c4d4d62 --- /dev/null +++ b/console/ui/src/widgets/clusters-table/assets/errorIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/console/ui/src/widgets/clusters-table/assets/noClustersIcon.svg b/console/ui/src/widgets/clusters-table/assets/noClustersIcon.svg new file mode 100644 index 000000000..9eb95a45e --- /dev/null +++ b/console/ui/src/widgets/clusters-table/assets/noClustersIcon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/console/ui/src/widgets/clusters-table/assets/warningIcon.svg b/console/ui/src/widgets/clusters-table/assets/warningIcon.svg new file mode 100644 index 000000000..19c88a48c --- /dev/null +++ b/console/ui/src/widgets/clusters-table/assets/warningIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/console/ui/src/widgets/clusters-table/index.ts b/console/ui/src/widgets/clusters-table/index.ts new file mode 100644 index 000000000..bd1d451f8 --- /dev/null +++ b/console/ui/src/widgets/clusters-table/index.ts @@ -0,0 +1,3 @@ +import ClustersTable from '@widgets/clusters-table/ui'; + +export default ClustersTable; diff --git a/console/ui/src/widgets/clusters-table/lib/functions.ts b/console/ui/src/widgets/clusters-table/lib/functions.ts new file mode 100644 index 000000000..07e2db023 --- /dev/null +++ b/console/ui/src/widgets/clusters-table/lib/functions.ts @@ -0,0 +1,82 @@ +import Index from '@app/router/routerPathsConfig'; +import { convertTimestampToReadableTime, generateAbsoluteRouterPath } from '@shared/lib/functions.ts'; +import { NavigateFunction } from 'react-router-dom'; +import { createMRTColumnHelper } from 'material-react-table'; +import { ClustersTableValues } from '@widgets/clusters-table/model/types.ts'; +import { RankingInfo, rankings, rankItem } from '@tanstack/match-sorter-utils'; +import { CLUSTER_STATUSES, CLUSTER_TABLE_COLUMN_NAMES } from '@widgets/clusters-table/model/constants.ts'; + +export const createClusterButtonHandler = (navigate: NavigateFunction) => () => + navigate(generateAbsoluteRouterPath(Index.clusters.add.absolutePath)); + +const columnHelper = createMRTColumnHelper(); + +export const getClusterTableColumns = ({ t, environmentOptions, postgresVersionOptions }) => [ + // note: changing table cell items content might need new custom filter function + columnHelper.accessor(CLUSTER_TABLE_COLUMN_NAMES.NAME, { + header: t('clusterName', { ns: 'clusters' }), + filterFn: (row: Row, id: string, filterValue: string | number, addMeta: (item: RankingInfo) => void) => { + // custom filter callback because of ReactNode as values + const itemRank = rankItem(row.getValue(CLUSTER_TABLE_COLUMN_NAMES.NAME).props.children, filterValue as string, { + threshold: rankings.MATCHES, + }); + addMeta(itemRank); + return itemRank.passed; + }, + size: 150, + enableHiding: false, + grow: true, + visibleInShowHideMenu: false, + }), + columnHelper.accessor(CLUSTER_TABLE_COLUMN_NAMES.STATUS, { + header: t('status', { ns: 'shared' }), + size: 120, + filterVariant: 'select', + filterSelectOptions: Object.values(CLUSTER_STATUSES), + filterFn: (row: Row, id: string, filterValue: string | number, addMeta: (item: RankingInfo) => void) => { + const itemRank = rankItem( + row.getValue(CLUSTER_TABLE_COLUMN_NAMES.STATUS).props.children[1].props.children, // custom filter callback because of ReactNode as values + filterValue as string, + { + threshold: rankings.MATCHES, + }, + ); + addMeta(itemRank); + return itemRank.passed; + }, + grow: true, + }), + columnHelper.accessor(CLUSTER_TABLE_COLUMN_NAMES.CREATION_TIME, { + accessorFn: (originalRow) => new Date(originalRow[CLUSTER_TABLE_COLUMN_NAMES.CREATION_TIME]), //convert to date for sorting and filtering + header: t('creationTime', { ns: 'clusters' }), + size: 260, + filterVariant: 'date-range', + grow: true, + muiFilterTextFieldProps: { sx: { display: 'flex', flexDirection: 'column' } }, + muiFilterDatePickerProps: { sx: { display: 'flex', flexDirection: 'column' }, size: 'small' }, + Cell: ({ cell }) => convertTimestampToReadableTime(cell.getValue()), // convert back to string to display + }), + columnHelper.accessor(CLUSTER_TABLE_COLUMN_NAMES.ENVIRONMENT, { + header: t('environment', { ns: 'shared' }), + size: 140, + filterVariant: 'select', + filterSelectOptions: environmentOptions, + grow: true, + }), + columnHelper.accessor(CLUSTER_TABLE_COLUMN_NAMES.SERVERS, { + header: t('servers', { ns: 'clusters' }), + size: 120, + grow: true, + }), + columnHelper.accessor(CLUSTER_TABLE_COLUMN_NAMES.POSTGRES_VERSION, { + header: t('postgresVersion', { ns: 'clusters' }), + size: 150, + filterVariant: 'select', + filterSelectOptions: postgresVersionOptions, + grow: true, + }), + columnHelper.accessor(CLUSTER_TABLE_COLUMN_NAMES.LOCATION, { + header: t('location', { ns: 'clusters' }), + grow: true, + }), +]; diff --git a/console/ui/src/widgets/clusters-table/lib/hooks.tsx b/console/ui/src/widgets/clusters-table/lib/hooks.tsx new file mode 100644 index 000000000..fe389f060 --- /dev/null +++ b/console/ui/src/widgets/clusters-table/lib/hooks.tsx @@ -0,0 +1,48 @@ +import { useMemo } from 'react'; +import { + CLUSTER_STATUSES, + CLUSTER_TABLE_COLUMN_NAMES, + clusterStatusColorNamesMap, +} from '@widgets/clusters-table/model/constants.ts'; +import { CircularProgress, Link, Stack, Typography } from '@mui/material'; +import { generateAbsoluteRouterPath } from '@shared/lib/functions.ts'; +import RouterPaths from '@app/router/routerPathsConfig'; +import { ClusterInfo } from '@shared/api/api/clusters.ts'; + +export const useGetClustersTableData = (data: ClusterInfo[]) => + useMemo( + () => + data?.map((cluster) => ({ + [CLUSTER_TABLE_COLUMN_NAMES.NAME]: [CLUSTER_STATUSES.DEPLOYING, CLUSTER_STATUSES.FAILED].some( + (status) => status === cluster.status, + ) ? ( + {cluster.name} + ) : ( + + {cluster.name} + + ), + [CLUSTER_TABLE_COLUMN_NAMES.STATUS]: ( + + {cluster.status === CLUSTER_STATUSES.DEPLOYING ? ( + + ) : clusterStatusColorNamesMap[cluster.status] ? ( + {cluster.status} + ) : null} + {cluster.status} + + ), + [CLUSTER_TABLE_COLUMN_NAMES.CREATION_TIME]: cluster.creation_time, + [CLUSTER_TABLE_COLUMN_NAMES.ENVIRONMENT]: cluster.environment, + [CLUSTER_TABLE_COLUMN_NAMES.SERVERS]: cluster.servers?.length ?? '-', + [CLUSTER_TABLE_COLUMN_NAMES.POSTGRES_VERSION]: cluster?.postgres_version ?? '-', + [CLUSTER_TABLE_COLUMN_NAMES.LOCATION]: cluster?.cluster_location ?? '-', + [CLUSTER_TABLE_COLUMN_NAMES.ID]: cluster.id, // not displayed, required only for correct cluster removal + })) ?? [], + [data], + ); diff --git a/console/ui/src/widgets/clusters-table/model/constants.ts b/console/ui/src/widgets/clusters-table/model/constants.ts new file mode 100644 index 000000000..d741dc5a5 --- /dev/null +++ b/console/ui/src/widgets/clusters-table/model/constants.ts @@ -0,0 +1,33 @@ +import CorrectIcon from '../assets/correctIcon.svg'; +import WarningIcon from '../assets/warningIcon.svg'; +import ErrorIcon from '../assets/errorIcon.svg'; + +export const CLUSTER_TABLE_COLUMN_NAMES = Object.freeze({ + // names are used as sorting params, changes will break sorting + NAME: 'name', + STATUS: 'status', + CREATION_TIME: 'created_at', + ENVIRONMENT: 'environment', + SERVERS: 'server_count', + POSTGRES_VERSION: 'postgres_version', + LOCATION: 'location', + ACTIONS: 'actions', + ID: 'id', +}); + +export const CLUSTER_STATUSES = Object.freeze({ + DEPLOYING: 'deploying', + READY: 'ready', + FAILED: 'failed', + HEALTHY: 'healthy', + UNHEALTHY: 'unhealthy', + DEGRADED: 'degraded', + UNAVAILABLE: 'unavailable', +}); + +export const clusterStatusColorNamesMap = Object.freeze({ + [CLUSTER_STATUSES.HEALTHY]: CorrectIcon, + [CLUSTER_STATUSES.UNHEALTHY]: WarningIcon, + [CLUSTER_STATUSES.DEGRADED]: ErrorIcon, + [CLUSTER_STATUSES.UNAVAILABLE]: ErrorIcon, +}); diff --git a/console/ui/src/widgets/clusters-table/model/types.ts b/console/ui/src/widgets/clusters-table/model/types.ts new file mode 100644 index 000000000..0c95caffd --- /dev/null +++ b/console/ui/src/widgets/clusters-table/model/types.ts @@ -0,0 +1,11 @@ +import { CLUSTER_TABLE_COLUMN_NAMES } from '@widgets/clusters-table/model/constants.ts'; + +export interface ClustersTableValues { + [CLUSTER_TABLE_COLUMN_NAMES.NAME]: string; + [CLUSTER_TABLE_COLUMN_NAMES.STATUS]: Element; + [CLUSTER_TABLE_COLUMN_NAMES.CREATION_TIME]: string; + [CLUSTER_TABLE_COLUMN_NAMES.ENVIRONMENT]: string; + [CLUSTER_TABLE_COLUMN_NAMES.SERVERS]: number; + [CLUSTER_TABLE_COLUMN_NAMES.POSTGRES_VERSION]: number; + [CLUSTER_TABLE_COLUMN_NAMES.LOCATION]: string; +} diff --git a/console/ui/src/widgets/clusters-table/ui/ClustersEmptyRowsFallback.tsx b/console/ui/src/widgets/clusters-table/ui/ClustersEmptyRowsFallback.tsx new file mode 100644 index 000000000..23b0b668c --- /dev/null +++ b/console/ui/src/widgets/clusters-table/ui/ClustersEmptyRowsFallback.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { Box, Button, Stack, Typography } from '@mui/material'; +import { createClusterButtonHandler } from '@widgets/clusters-table/lib/functions.ts'; +import DatabaseIcon from '@assets/databaseIcon.svg?react'; + +const ClustersEmptyRowsFallback: React.FC = () => { + const { t } = useTranslation('clusters'); + const navigate = useNavigate(); + + return ( + + + + + + {t('noPostgresClustersTitle')} + + + {t('noPostgresClustersLine1', { createCluster: t('createCluster') })} + + + + + + ); +}; + +export default ClustersEmptyRowsFallback; diff --git a/console/ui/src/widgets/clusters-table/ui/index.tsx b/console/ui/src/widgets/clusters-table/ui/index.tsx new file mode 100644 index 000000000..292de4c6b --- /dev/null +++ b/console/ui/src/widgets/clusters-table/ui/index.tsx @@ -0,0 +1,99 @@ +import { FC, useMemo, useState } from 'react'; +import { CLUSTER_TABLE_COLUMN_NAMES } from '@widgets/clusters-table/model/constants.ts'; +import { useTranslation } from 'react-i18next'; +import { ClustersTableValues } from '@widgets/clusters-table/model/types.ts'; +import { MRT_ColumnDef, MRT_RowData, MRT_TableOptions } from 'material-react-table'; +import { useAppSelector } from '@app/redux/store/hooks.ts'; +import { selectCurrentProject } from '@app/redux/slices/projectSlice/projectSelectors.ts'; +import { CLUSTERS_POLLING_INTERVAL, PAGINATION_LIMIT_OPTIONS } from '@shared/config/constants.ts'; +import ClustersTableButtons from '@features/clusters-table-buttons'; +import { useGetClustersQuery } from '@shared/api/api/clusters.ts'; +import { useGetClustersTableData } from '@widgets/clusters-table/lib/hooks.tsx'; +import { useGetEnvironmentsQuery } from '@shared/api/api/environments.ts'; +import { useGetPostgresVersionsQuery } from '@shared/api/api/other.ts'; +import ClustersTableRowActions from '@features/clusters-table-row-actions'; +import ClustersEmptyRowsFallback from '@widgets/clusters-table/ui/ClustersEmptyRowsFallback.tsx'; +import { getClusterTableColumns } from '@widgets/clusters-table/lib/functions.ts'; +import { manageSortingOrder } from '@shared/lib/functions.ts'; +import { useQueryPolling } from '@shared/lib/hooks.tsx'; +import DefaultTable from '@shared/ui/default-table'; + +const ClustersTable: FC = () => { + const { t, i18n } = useTranslation('clusters'); + + const currentProject = useAppSelector(selectCurrentProject); + + const [sorting, setSorting] = useState([ + { + id: CLUSTER_TABLE_COLUMN_NAMES.CREATION_TIME, + desc: true, + }, + ]); + + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: PAGINATION_LIMIT_OPTIONS[1].value, + }); + + const environments = useGetEnvironmentsQuery({ offset: 0, limit: 999_999_999 }); + const postgresVersions = useGetPostgresVersionsQuery(); + + const clustersList = useQueryPolling( + () => + useGetClustersQuery({ + projectId: Number(currentProject), // TODO: projectId, projectCode + offset: pagination.pageIndex * pagination.pageSize, + limit: pagination.pageSize, + ...(sorting?.[0] ? { sortBy: manageSortingOrder(sorting[0]) } : {}), + }), + CLUSTERS_POLLING_INTERVAL, + ); + + const columns = useMemo[]>( + () => + getClusterTableColumns({ + t, + environmentOptions: environments.data?.data?.map((environment) => environment.name) ?? [], + postgresVersionOptions: postgresVersions.data?.data?.map((version) => version.major_version) ?? [], + }), + [i18n.language, environments.data?.data, postgresVersions.data?.data], + ); + + const data = useGetClustersTableData(clustersList.data?.data); + + const tableConfig: MRT_TableOptions = { + columns, + data, + enablePagination: true, + enableRowSelection: true, + showGlobalFilter: true, + manualPagination: true, + enableRowActions: true, + enableStickyHeader: true, + enableMultiSort: false, + onPaginationChange: setPagination, + onSortingChange: setSorting, + rowCount: clustersList.data?.meta?.count ?? 0, + state: { + isLoading: clustersList.isFetching || environments.isFetching || postgresVersions.isFetching, + pagination, + sorting, + }, + initialState: { + columnVisibility: { + [CLUSTER_TABLE_COLUMN_NAMES.LOCATION]: false, + }, + }, + renderRowActionMenuItems: ({ closeMenu, row }) => , + renderEmptyRowsFallback: () => , + }; + + return ( + <> + + + + ); +}; + +export default ClustersTable; diff --git a/console/ui/src/widgets/environments-table/index.ts b/console/ui/src/widgets/environments-table/index.ts new file mode 100644 index 000000000..32d4c9056 --- /dev/null +++ b/console/ui/src/widgets/environments-table/index.ts @@ -0,0 +1,3 @@ +import EnvironmentsTable from '@widgets/environments-table/ui'; + +export default EnvironmentsTable; diff --git a/console/ui/src/widgets/environments-table/lib/hooks.tsx b/console/ui/src/widgets/environments-table/lib/hooks.tsx new file mode 100644 index 000000000..9f7293f36 --- /dev/null +++ b/console/ui/src/widgets/environments-table/lib/hooks.tsx @@ -0,0 +1,16 @@ +import { useMemo } from 'react'; +import { ENVIRONMENTS_TABLE_COLUMN_NAMES } from '@widgets/environments-table/model/constants.ts'; +import { ResponseEnvironment } from '@shared/api/api/environments.ts'; + +export const useGetEnvironmentsTableData = (data: ResponseEnvironment[]) => + useMemo( + () => + data?.map((secret) => ({ + [ENVIRONMENTS_TABLE_COLUMN_NAMES.ID]: secret.id, + [ENVIRONMENTS_TABLE_COLUMN_NAMES.NAME]: secret.name, + [ENVIRONMENTS_TABLE_COLUMN_NAMES.CREATED]: secret.created_at, + [ENVIRONMENTS_TABLE_COLUMN_NAMES.UPDATED]: secret.updated_at, + [ENVIRONMENTS_TABLE_COLUMN_NAMES.DESCRIPTION]: secret.description ?? '-', + })) ?? [], + [data], + ); diff --git a/console/ui/src/widgets/environments-table/model/constants.ts b/console/ui/src/widgets/environments-table/model/constants.ts new file mode 100644 index 000000000..f63231108 --- /dev/null +++ b/console/ui/src/widgets/environments-table/model/constants.ts @@ -0,0 +1,44 @@ +import { createMRTColumnHelper } from 'material-react-table'; +import { TFunction } from 'i18next'; +import { convertTimestampToReadableTime } from '@shared/lib/functions.ts'; +import { EnvironmentTableValues } from '@widgets/environments-table/model/types.ts'; + +export const ENVIRONMENTS_TABLE_COLUMN_NAMES = Object.freeze({ + ID: 'id', + NAME: 'name', + DESCRIPTION: 'description', + CREATED: 'created_at', + UPDATED: 'updated_at', +}); + +const columnHelper = createMRTColumnHelper(); + +export const environmentTableColumns = (t: TFunction) => [ + columnHelper.accessor(ENVIRONMENTS_TABLE_COLUMN_NAMES.ID, { + header: t('id', { ns: 'shared' }), + size: 80, + grow: true, + }), + columnHelper.accessor(ENVIRONMENTS_TABLE_COLUMN_NAMES.NAME, { + header: t('name', { ns: 'shared' }), + size: 80, + grow: true, + }), + columnHelper.accessor(ENVIRONMENTS_TABLE_COLUMN_NAMES.CREATED, { + header: t('created', { ns: 'shared' }), + size: 150, + grow: true, + Cell: ({ cell }) => convertTimestampToReadableTime(cell.getValue()), // convert back to string for display + }), + columnHelper.accessor(ENVIRONMENTS_TABLE_COLUMN_NAMES.UPDATED, { + header: t('updated', { ns: 'shared' }), + size: 150, + grow: true, + Cell: ({ cell }) => convertTimestampToReadableTime(cell.getValue()), // convert back to string for display + }), + columnHelper.accessor(ENVIRONMENTS_TABLE_COLUMN_NAMES.DESCRIPTION, { + header: t('description', { ns: 'shared' }), + size: 150, + grow: true, + }), +]; diff --git a/console/ui/src/widgets/environments-table/model/types.ts b/console/ui/src/widgets/environments-table/model/types.ts new file mode 100644 index 000000000..3c1719038 --- /dev/null +++ b/console/ui/src/widgets/environments-table/model/types.ts @@ -0,0 +1,9 @@ +import { PROJECTS_TABLE_COLUMN_NAMES } from '@widgets/projects-table/model/constants.ts'; + +export interface EnvironmentTableValues { + [PROJECTS_TABLE_COLUMN_NAMES.ID]: string; + [PROJECTS_TABLE_COLUMN_NAMES.NAME]: string; + [PROJECTS_TABLE_COLUMN_NAMES.DESCRIPTION]: 'description'; + [PROJECTS_TABLE_COLUMN_NAMES.CREATED]: 'created_at'; + [PROJECTS_TABLE_COLUMN_NAMES.UPDATED]: 'updated_at'; +} diff --git a/console/ui/src/widgets/environments-table/ui/EnvironmentsTableButtons.tsx b/console/ui/src/widgets/environments-table/ui/EnvironmentsTableButtons.tsx new file mode 100644 index 000000000..71a41b78e --- /dev/null +++ b/console/ui/src/widgets/environments-table/ui/EnvironmentsTableButtons.tsx @@ -0,0 +1,13 @@ +import { FC } from 'react'; +import { Stack } from '@mui/material'; +import AddEnvironment from '@features/add-environment'; + +const EnvironmentsTableButtons: FC = () => { + return ( + + + + ); +}; + +export default EnvironmentsTableButtons; diff --git a/console/ui/src/widgets/environments-table/ui/index.tsx b/console/ui/src/widgets/environments-table/ui/index.tsx new file mode 100644 index 000000000..9484f049b --- /dev/null +++ b/console/ui/src/widgets/environments-table/ui/index.tsx @@ -0,0 +1,58 @@ +import React, { FC, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PAGINATION_LIMIT_OPTIONS } from '@shared/config/constants.ts'; +import { MRT_ColumnDef, MRT_RowData, MRT_TableOptions } from 'material-react-table'; +import { EnvironmentTableValues } from '@widgets/environments-table/model/types.ts'; +import { environmentTableColumns } from '@widgets/environments-table/model/constants.ts'; +import { useGetEnvironmentsTableData } from '@widgets/environments-table/lib/hooks.tsx'; +import { useGetEnvironmentsQuery } from '@shared/api/api/environments.ts'; +import EnvironmentsTableButtons from '@widgets/environments-table/ui/EnvironmentsTableButtons.tsx'; +import EnvironmentsTableRowActions from '@features/environments-table-row-actions/ui'; +import DefaultTable from '@shared/ui/default-table'; + +const EnvironmentsTable: FC = () => { + const { t, i18n } = useTranslation(['settings', 'shared']); + + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: PAGINATION_LIMIT_OPTIONS[1].value, + }); + + const environmentsList = useGetEnvironmentsQuery({ + offset: pagination.pageIndex * pagination.pageSize, + limit: pagination.pageSize, + }); + + const columns = useMemo[]>(() => environmentTableColumns(t), [i18n.language]); + + const data = useGetEnvironmentsTableData(environmentsList.data?.data); + + const tableConfig: MRT_TableOptions = { + columns, + data, + enablePagination: true, + enableRowSelection: true, + showGlobalFilter: true, + enableRowActions: true, + enableStickyHeader: true, + enableMultiSort: false, + enableSorting: false, + onPaginationChange: setPagination, + manualPagination: true, + rowCount: environmentsList.data?.meta?.count ?? 0, + state: { + isLoading: environmentsList.isFetching, + pagination, + }, + renderRowActionMenuItems: ({ closeMenu, row }) => , + }; + + return ( + <> + + + + ); +}; + +export default EnvironmentsTable; diff --git a/console/ui/src/widgets/header/index.ts b/console/ui/src/widgets/header/index.ts new file mode 100644 index 000000000..eab9de354 --- /dev/null +++ b/console/ui/src/widgets/header/index.ts @@ -0,0 +1,3 @@ +import Header from '@widgets/header/ui'; + +export default Header; diff --git a/console/ui/src/widgets/header/ui/index.tsx b/console/ui/src/widgets/header/ui/index.tsx new file mode 100644 index 000000000..47d60fc26 --- /dev/null +++ b/console/ui/src/widgets/header/ui/index.tsx @@ -0,0 +1,64 @@ +import { FC, useEffect } from 'react'; +import { AppBar, Box, MenuItem, SelectChangeEvent, Stack, TextField, Toolbar, Typography } from '@mui/material'; +import Logo from '@shared/assets/PGCLogo.svg?react'; +import { grey } from '@mui/material/colors'; +import LogoutButton from '@features/logout-button'; +import { useGetProjectsQuery } from '@shared/api/api/projects.ts'; +import { setProject } from '@app/redux/slices/projectSlice/projectSlice.ts'; +import { selectCurrentProject } from '@app/redux/slices/projectSlice/projectSelectors.ts'; +import { useAppDispatch, useAppSelector } from '@app/redux/store/hooks.ts'; +import { useTranslation } from 'react-i18next'; + +const Header: FC = () => { + const { t } = useTranslation('shared'); + const dispatch = useAppDispatch(); + const currentProject = useAppSelector(selectCurrentProject); + + const projects = useGetProjectsQuery({ limit: 999_999_999 }); + + useEffect(() => { + if (!currentProject && projects.data?.data) dispatch(setProject(String(projects.data?.data?.[0]?.id))); + }, [projects.data?.data, dispatch, currentProject]); + + const handleProjectChange = (e: SelectChangeEvent) => { + dispatch(setProject(e.target.value)); + }; + + return ( + theme.zIndex.drawer + 1 }} elevation={0} variant="outlined"> + + + + + + + + PostgreSQL Cluster + + + Console + + + + + {projects.data?.data?.map((project) => ( + + {project.name} + + )) ?? []} + + + + + + + ); +}; + +export default Header; diff --git a/console/ui/src/widgets/main/index.ts b/console/ui/src/widgets/main/index.ts new file mode 100644 index 000000000..cf887abaa --- /dev/null +++ b/console/ui/src/widgets/main/index.ts @@ -0,0 +1,3 @@ +import Main from '@widgets/main/ui'; + +export default Main; diff --git a/console/ui/src/widgets/main/ui/index.tsx b/console/ui/src/widgets/main/ui/index.tsx new file mode 100644 index 000000000..5bf620c68 --- /dev/null +++ b/console/ui/src/widgets/main/ui/index.tsx @@ -0,0 +1,20 @@ +import { FC, Suspense } from 'react'; +import { Divider, Stack, Toolbar } from '@mui/material'; +import { Outlet } from 'react-router-dom'; +import Breadcrumbs from '@features/bradcrumbs'; +import Spinner from '@shared/ui/spinner'; + +const Main: FC = () => ( +
+ + + + + }> + + + +
+); + +export default Main; diff --git a/console/ui/src/widgets/operations-table/index.ts b/console/ui/src/widgets/operations-table/index.ts new file mode 100644 index 000000000..e6de8fafd --- /dev/null +++ b/console/ui/src/widgets/operations-table/index.ts @@ -0,0 +1,3 @@ +import OperationsTable from '@widgets/operations-table/ui'; + +export default OperationsTable; diff --git a/console/ui/src/widgets/operations-table/lib/hooks.tsx b/console/ui/src/widgets/operations-table/lib/hooks.tsx new file mode 100644 index 000000000..e1223a7c3 --- /dev/null +++ b/console/ui/src/widgets/operations-table/lib/hooks.tsx @@ -0,0 +1,18 @@ +import { useMemo } from 'react'; +import { OPERATIONS_TABLE_COLUMN_NAMES } from '@widgets/operations-table/model/constants.ts'; +import { ResponseOperation } from '@shared/api/api/operations.ts'; + +export const useGetOperationsTableData = (data: ResponseOperation[]) => + useMemo( + () => + data?.map((operation) => ({ + [OPERATIONS_TABLE_COLUMN_NAMES.ID]: operation.id!, + [OPERATIONS_TABLE_COLUMN_NAMES.CLUSTER]: operation.cluster_name!, + [OPERATIONS_TABLE_COLUMN_NAMES.STARTED]: operation.started, + [OPERATIONS_TABLE_COLUMN_NAMES.FINISHED]: operation.status === 'in_progress' ? '-' : operation?.finished ?? '-', + [OPERATIONS_TABLE_COLUMN_NAMES.TYPE]: operation.type!, + [OPERATIONS_TABLE_COLUMN_NAMES.STATUS]: operation.status!, + [OPERATIONS_TABLE_COLUMN_NAMES.ENVIRONMENT]: operation.environment!, + })) ?? [], + [data], + ); diff --git a/console/ui/src/widgets/operations-table/model/constants.ts b/console/ui/src/widgets/operations-table/model/constants.ts new file mode 100644 index 000000000..bddc32254 --- /dev/null +++ b/console/ui/src/widgets/operations-table/model/constants.ts @@ -0,0 +1,59 @@ +import { TFunction } from 'i18next'; +import { createMRTColumnHelper } from 'material-react-table'; +import { OperationsTableValues } from '@widgets/operations-table/model/types.ts'; +import { convertTimestampToReadableTime } from '@shared/lib/functions.ts'; + +export const OPERATIONS_TABLE_COLUMN_NAMES = Object.freeze({ + // names are used as sorting params, changes will break sorting + ID: 'id', + STARTED: 'created_at', + FINISHED: 'updated_at', + TYPE: 'type', + STATUS: 'status', + CLUSTER: 'cluster_name', + ENVIRONMENT: 'environment', + ACTIONS: 'actions', +}); + +const columnHelper = createMRTColumnHelper(); + +export const operationTableColumns = (t: TFunction) => [ + columnHelper.accessor(OPERATIONS_TABLE_COLUMN_NAMES.ID, { + header: t('id', { ns: 'shared' }), + size: 80, + grow: true, + visibleInShowHideMenu: false, + }), + columnHelper.accessor(OPERATIONS_TABLE_COLUMN_NAMES.STARTED, { + header: t('started', { ns: 'operations' }), + grow: true, + size: 120, + Cell: ({ cell }) => convertTimestampToReadableTime(cell.getValue()), + }), + columnHelper.accessor(OPERATIONS_TABLE_COLUMN_NAMES.FINISHED, { + header: t('finished', { ns: 'operations' }), + grow: true, + size: 120, + Cell: ({ cell }) => convertTimestampToReadableTime(cell.getValue()), + }), + columnHelper.accessor(OPERATIONS_TABLE_COLUMN_NAMES.TYPE, { + header: t('type', { ns: 'operations' }), + grow: true, + size: 60, + }), + columnHelper.accessor(OPERATIONS_TABLE_COLUMN_NAMES.STATUS, { + header: t('status', { ns: 'shared' }), + grow: true, + size: 80, + }), + columnHelper.accessor(OPERATIONS_TABLE_COLUMN_NAMES.CLUSTER, { + header: t('cluster', { ns: 'clusters' }), + grow: true, + size: 140, + }), + columnHelper.accessor(OPERATIONS_TABLE_COLUMN_NAMES.ENVIRONMENT, { + header: t('environment', { ns: 'shared' }), + grow: true, + size: 140, + }), +]; diff --git a/console/ui/src/widgets/operations-table/model/types.ts b/console/ui/src/widgets/operations-table/model/types.ts new file mode 100644 index 000000000..46304e31f --- /dev/null +++ b/console/ui/src/widgets/operations-table/model/types.ts @@ -0,0 +1,11 @@ +import { OPERATIONS_TABLE_COLUMN_NAMES } from '@widgets/operations-table/model/constants.ts'; + +export interface OperationsTableValues { + [OPERATIONS_TABLE_COLUMN_NAMES.ID]: number; + [OPERATIONS_TABLE_COLUMN_NAMES.STARTED]: string; + [OPERATIONS_TABLE_COLUMN_NAMES.FINISHED]: string; + [OPERATIONS_TABLE_COLUMN_NAMES.TYPE]: string; + [OPERATIONS_TABLE_COLUMN_NAMES.STATUS]: string; + [OPERATIONS_TABLE_COLUMN_NAMES.CLUSTER]: string; + [OPERATIONS_TABLE_COLUMN_NAMES.ENVIRONMENT]: string; +} diff --git a/console/ui/src/widgets/operations-table/ui/index.tsx b/console/ui/src/widgets/operations-table/ui/index.tsx new file mode 100644 index 000000000..9c5dc1e22 --- /dev/null +++ b/console/ui/src/widgets/operations-table/ui/index.tsx @@ -0,0 +1,90 @@ +import { FC, useMemo, useState } from 'react'; +import { MRT_ColumnDef, MRT_RowData, MRT_TableOptions } from 'material-react-table'; +import { OPERATIONS_TABLE_COLUMN_NAMES, operationTableColumns } from '@widgets/operations-table/model/constants.ts'; +import { useTranslation } from 'react-i18next'; +import { OperationsTableValues } from '@widgets/operations-table/model/types.ts'; +import OperationsTableButtons from '@features/operations-table-buttons'; +import OperationsTableRowActions from '@features/operations-table-row-actions'; +import { useGetOperationsQuery } from '@shared/api/api/operations.ts'; +import { useAppSelector } from '@app/redux/store/hooks.ts'; +import { selectCurrentProject } from '@app/redux/slices/projectSlice/projectSelectors.ts'; +import { subDays } from 'date-fns/subDays'; +import { + formatOperationsDate, + getOperationsDateRangeVariants, +} from '@features/operations-table-buttons/lib/functions.ts'; +import { OPERATIONS_POLLING_INTERVAL, PAGINATION_LIMIT_OPTIONS } from '@shared/config/constants.ts'; +import { useGetOperationsTableData } from '@widgets/operations-table/lib/hooks.tsx'; +import { manageSortingOrder } from '@shared/lib/functions.ts'; +import { useQueryPolling } from '@shared/lib/hooks.tsx'; +import DefaultTable from '@shared/ui/default-table'; + +const OperationsTable: FC = () => { + const { t, i18n } = useTranslation(['operations', 'shared']); + + const currentProject = useAppSelector(selectCurrentProject); + + const [sorting, setSorting] = useState([ + { + id: OPERATIONS_TABLE_COLUMN_NAMES.ID, + desc: true, + }, + ]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: PAGINATION_LIMIT_OPTIONS[1].value, + }); + + const [endDate] = useState(new Date().toISOString()); + const [startDate, setStartDate] = useState({ + name: getOperationsDateRangeVariants(t)[0].value, + value: formatOperationsDate(subDays(new Date(), 1)), + }); + + const operationsList = useQueryPolling( + () => + useGetOperationsQuery({ + projectId: Number(currentProject), + startDate: startDate.value, + endDate, + offset: pagination.pageIndex * pagination.pageSize, + limit: pagination.pageSize, + ...(sorting?.[0] ? { sortBy: manageSortingOrder(sorting[0]) } : {}), + }), + OPERATIONS_POLLING_INTERVAL, + ); + + const columns = useMemo[]>(() => operationTableColumns(t), [i18n.language]); + + const data = useGetOperationsTableData(operationsList.data?.data); + + const tableConfig: MRT_TableOptions = { + columns, + data, + enablePagination: true, + showGlobalFilter: true, + manualSorting: true, + manualPagination: true, + enableRowActions: true, + enableStickyHeader: true, + enableMultiSort: false, + onPaginationChange: setPagination, + onSortingChange: setSorting, + rowCount: operationsList.data?.meta?.count ?? 0, + state: { + isLoading: operationsList.isFetching, + pagination, + sorting, + }, + renderRowActionMenuItems: ({ closeMenu, row }) => , + }; + + return ( + <> + + + + ); +}; + +export default OperationsTable; diff --git a/console/ui/src/widgets/projects-table/index.tsx b/console/ui/src/widgets/projects-table/index.tsx new file mode 100644 index 000000000..398bdd3ef --- /dev/null +++ b/console/ui/src/widgets/projects-table/index.tsx @@ -0,0 +1,3 @@ +import ProjectsTable from '@widgets/projects-table/ui'; + +export default ProjectsTable; diff --git a/console/ui/src/widgets/projects-table/lib/hooks.tsx b/console/ui/src/widgets/projects-table/lib/hooks.tsx new file mode 100644 index 000000000..456940f7f --- /dev/null +++ b/console/ui/src/widgets/projects-table/lib/hooks.tsx @@ -0,0 +1,16 @@ +import { useMemo } from 'react'; +import { ResponseProject } from '@shared/api/api/projects.ts'; +import { PROJECTS_TABLE_COLUMN_NAMES } from '@widgets/projects-table/model/constants.ts'; + +export const useGetProjectsTableData = (data: ResponseProject[]) => + useMemo( + () => + data?.map((secret) => ({ + [PROJECTS_TABLE_COLUMN_NAMES.ID]: secret.id, + [PROJECTS_TABLE_COLUMN_NAMES.NAME]: secret.name, + [PROJECTS_TABLE_COLUMN_NAMES.CREATED]: secret.created_at, + [PROJECTS_TABLE_COLUMN_NAMES.UPDATED]: secret.updated_at, + [PROJECTS_TABLE_COLUMN_NAMES.DESCRIPTION]: secret.description ?? '-', + })) ?? [], + [data], + ); diff --git a/console/ui/src/widgets/projects-table/model/constants.ts b/console/ui/src/widgets/projects-table/model/constants.ts new file mode 100644 index 000000000..a9e758097 --- /dev/null +++ b/console/ui/src/widgets/projects-table/model/constants.ts @@ -0,0 +1,44 @@ +import { createMRTColumnHelper } from 'material-react-table'; +import { TFunction } from 'i18next'; +import { convertTimestampToReadableTime } from '@shared/lib/functions.ts'; +import { ProjectsTableValues } from '@widgets/projects-table/model/types.ts'; + +export const PROJECTS_TABLE_COLUMN_NAMES = Object.freeze({ + ID: 'id', + NAME: 'name', + DESCRIPTION: 'description', + CREATED: 'created_at', + UPDATED: 'updated_at', +}); + +const columnHelper = createMRTColumnHelper(); + +export const projectsTableColumns = (t: TFunction) => [ + columnHelper.accessor(PROJECTS_TABLE_COLUMN_NAMES.ID, { + header: t('id', { ns: 'shared' }), + size: 80, + grow: true, + }), + columnHelper.accessor(PROJECTS_TABLE_COLUMN_NAMES.NAME, { + header: t('name', { ns: 'shared' }), + size: 80, + grow: true, + }), + columnHelper.accessor(PROJECTS_TABLE_COLUMN_NAMES.CREATED, { + header: t('created', { ns: 'shared' }), + size: 150, + grow: true, + Cell: ({ cell }) => convertTimestampToReadableTime(cell.getValue()), // convert back to string for display + }), + columnHelper.accessor(PROJECTS_TABLE_COLUMN_NAMES.UPDATED, { + header: t('updated', { ns: 'shared' }), + size: 150, + grow: true, + Cell: ({ cell }) => convertTimestampToReadableTime(cell.getValue()), // convert back to string for display + }), + columnHelper.accessor(PROJECTS_TABLE_COLUMN_NAMES.DESCRIPTION, { + header: t('description', { ns: 'shared' }), + size: 150, + grow: true, + }), +]; diff --git a/console/ui/src/widgets/projects-table/model/types.ts b/console/ui/src/widgets/projects-table/model/types.ts new file mode 100644 index 000000000..accbdd8d5 --- /dev/null +++ b/console/ui/src/widgets/projects-table/model/types.ts @@ -0,0 +1,6 @@ +import { PROJECTS_TABLE_COLUMN_NAMES } from '@widgets/projects-table/model/constants.ts'; + +export interface ProjectsTableValues { + [PROJECTS_TABLE_COLUMN_NAMES.ID]: string; + [PROJECTS_TABLE_COLUMN_NAMES.NAME]: string; +} diff --git a/console/ui/src/widgets/projects-table/ui/ProjectsTableButtons.tsx b/console/ui/src/widgets/projects-table/ui/ProjectsTableButtons.tsx new file mode 100644 index 000000000..3875747a2 --- /dev/null +++ b/console/ui/src/widgets/projects-table/ui/ProjectsTableButtons.tsx @@ -0,0 +1,13 @@ +import { FC } from 'react'; +import AddProject from '@features/add-project'; +import { Stack } from '@mui/material'; + +const ProjectsTableButtons: FC = () => { + return ( + + + + ); +}; + +export default ProjectsTableButtons; diff --git a/console/ui/src/widgets/projects-table/ui/index.tsx b/console/ui/src/widgets/projects-table/ui/index.tsx new file mode 100644 index 000000000..1820a5a19 --- /dev/null +++ b/console/ui/src/widgets/projects-table/ui/index.tsx @@ -0,0 +1,58 @@ +import React, { FC, useMemo, useState } from 'react'; +import { PAGINATION_LIMIT_OPTIONS } from '@shared/config/constants.ts'; +import { MRT_ColumnDef, MRT_RowData, MRT_TableOptions } from 'material-react-table'; +import { ProjectsTableValues } from '@widgets/projects-table/model/types.ts'; +import { projectsTableColumns } from '@widgets/projects-table/model/constants.ts'; +import { useTranslation } from 'react-i18next'; +import { useGetProjectsTableData } from '@widgets/projects-table/lib/hooks.tsx'; +import { useGetProjectsQuery } from '@shared/api/api/projects.ts'; +import ProjectsTableButtons from '@widgets/projects-table/ui/ProjectsTableButtons.tsx'; +import ProjectsTableRowActions from '@features/pojects-table-row-actions'; +import DefaultTable from '@shared/ui/default-table'; + +const ProjectsTable: FC = () => { + const { t, i18n } = useTranslation(['settings', 'shared']); + + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: PAGINATION_LIMIT_OPTIONS[1].value, + }); + + const projectsList = useGetProjectsQuery({ + offset: pagination.pageIndex * pagination.pageSize, + limit: pagination.pageSize, + }); + + const columns = useMemo[]>(() => projectsTableColumns(t), [i18n.language]); + + const data = useGetProjectsTableData(projectsList.data?.data); + + const tableConfig: MRT_TableOptions = { + columns, + data, + enablePagination: true, + enableRowSelection: true, + showGlobalFilter: true, + enableRowActions: true, + enableStickyHeader: true, + enableMultiSort: false, + enableSorting: false, + onPaginationChange: setPagination, + manualPagination: true, + rowCount: projectsList.data?.meta?.count ?? 0, + state: { + isLoading: projectsList.isFetching, + pagination, + }, + renderRowActionMenuItems: ({ closeMenu, row }) => , + }; + + return ( + <> + + + + ); +}; + +export default ProjectsTable; diff --git a/console/ui/src/widgets/secrets-table/index.ts b/console/ui/src/widgets/secrets-table/index.ts new file mode 100644 index 000000000..cc40093da --- /dev/null +++ b/console/ui/src/widgets/secrets-table/index.ts @@ -0,0 +1,3 @@ +import SecretsTable from '@widgets/secrets-table/ui'; + +export default SecretsTable; diff --git a/console/ui/src/widgets/secrets-table/lib/hooks.tsx b/console/ui/src/widgets/secrets-table/lib/hooks.tsx new file mode 100644 index 000000000..6fb0035a0 --- /dev/null +++ b/console/ui/src/widgets/secrets-table/lib/hooks.tsx @@ -0,0 +1,18 @@ +import { ResponseSecretInfo } from '@shared/api/api/secrets.ts'; +import { useMemo } from 'react'; +import { SECRETS_TABLE_COLUMN_NAMES } from '@widgets/secrets-table/model/constants.ts'; + +export const useGetSecretsTableData = (data: ResponseSecretInfo[]) => + useMemo( + () => + data?.map((secret) => ({ + [SECRETS_TABLE_COLUMN_NAMES.NAME]: secret.name!, + [SECRETS_TABLE_COLUMN_NAMES.TYPE]: secret.type!, + [SECRETS_TABLE_COLUMN_NAMES.CREATED]: secret.created_at, + [SECRETS_TABLE_COLUMN_NAMES.UPDATED]: secret.updated_at, + [SECRETS_TABLE_COLUMN_NAMES.USED]: String(!!secret.is_used), + [SECRETS_TABLE_COLUMN_NAMES.ID]: secret.id!, + [SECRETS_TABLE_COLUMN_NAMES.USED_BY]: secret.used_by_clusters, // not displayed, required only for logic purposed + })) ?? [], + [data], + ); diff --git a/console/ui/src/widgets/secrets-table/model/constants.ts b/console/ui/src/widgets/secrets-table/model/constants.ts new file mode 100644 index 000000000..be74a900a --- /dev/null +++ b/console/ui/src/widgets/secrets-table/model/constants.ts @@ -0,0 +1,51 @@ +import { TFunction } from 'i18next'; +import { createMRTColumnHelper } from 'material-react-table'; +import { SecretsTableValues } from '@widgets/secrets-table/model/types.ts'; +import { convertTimestampToReadableTime } from '@shared/lib/functions.ts'; + +export const SECRETS_TABLE_COLUMN_NAMES = Object.freeze({ + NAME: 'name', + TYPE: 'type', + CREATED: 'created', + UPDATED: 'updated', + USED: 'used', + ID: 'id', + USED_BY: 'usedBy', +}); + +const columnHelper = createMRTColumnHelper(); + +export const secretsTableColumns = (t: TFunction) => [ + columnHelper.accessor(SECRETS_TABLE_COLUMN_NAMES.NAME, { + header: t('name', { ns: 'shared' }), + size: 80, + grow: true, + }), + columnHelper.accessor(SECRETS_TABLE_COLUMN_NAMES.TYPE, { + header: t('type', { ns: 'shared' }), + size: 80, + grow: true, + }), + columnHelper.accessor(SECRETS_TABLE_COLUMN_NAMES.CREATED, { + header: t('created', { ns: 'shared' }), + size: 150, + grow: true, + Cell: ({ cell }) => convertTimestampToReadableTime(cell.getValue()), // convert back to string for display + }), + columnHelper.accessor(SECRETS_TABLE_COLUMN_NAMES.UPDATED, { + header: t('updated', { ns: 'shared' }), + size: 150, + grow: true, + Cell: ({ cell }) => convertTimestampToReadableTime(cell.getValue()), // convert back to string for display + }), + columnHelper.accessor(SECRETS_TABLE_COLUMN_NAMES.USED, { + header: t('used', { ns: 'shared' }), + size: 150, + grow: true, + }), + columnHelper.accessor(SECRETS_TABLE_COLUMN_NAMES.ID, { + header: t('id', { ns: 'shared' }), + size: 80, + grow: true, + }), +]; diff --git a/console/ui/src/widgets/secrets-table/model/types.ts b/console/ui/src/widgets/secrets-table/model/types.ts new file mode 100644 index 000000000..4a0a545f5 --- /dev/null +++ b/console/ui/src/widgets/secrets-table/model/types.ts @@ -0,0 +1,10 @@ +import { SECRETS_TABLE_COLUMN_NAMES } from '@widgets/secrets-table/model/constants.ts'; + +export interface SecretsTableValues { + [SECRETS_TABLE_COLUMN_NAMES.NAME]: string; + [SECRETS_TABLE_COLUMN_NAMES.TYPE]: string; + [SECRETS_TABLE_COLUMN_NAMES.CREATED]: string; + [SECRETS_TABLE_COLUMN_NAMES.UPDATED]: string; + [SECRETS_TABLE_COLUMN_NAMES.USED]: string; + [SECRETS_TABLE_COLUMN_NAMES.ID]: number; +} diff --git a/console/ui/src/widgets/secrets-table/ui/index.tsx b/console/ui/src/widgets/secrets-table/ui/index.tsx new file mode 100644 index 000000000..b08ff5c4a --- /dev/null +++ b/console/ui/src/widgets/secrets-table/ui/index.tsx @@ -0,0 +1,64 @@ +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { MRT_ColumnDef, MRT_RowData, MRT_TableOptions } from 'material-react-table'; +import { SecretsTableValues } from '@widgets/secrets-table/model/types.ts'; +import SettingsTableRowActions from '@features/settings-table-row-actions'; +import SettingsTableButtons from '@features/settings-table-buttons'; +import { useGetSecretsQuery } from '@shared/api/api/secrets.ts'; +import { PAGINATION_LIMIT_OPTIONS } from '@shared/config/constants.ts'; +import { useAppSelector } from '@app/redux/store/hooks.ts'; +import { selectCurrentProject } from '@app/redux/slices/projectSlice/projectSelectors.ts'; +import { secretsTableColumns } from '@widgets/secrets-table/model/constants.ts'; + +import { useGetSecretsTableData } from '@widgets/secrets-table/lib/hooks.tsx'; +import DefaultTable from '@shared/ui/default-table'; + +const SecretsTable: React.FC = () => { + const { t, i18n } = useTranslation(['settings', 'shared']); + + const currentProject = useAppSelector(selectCurrentProject); + + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: PAGINATION_LIMIT_OPTIONS[1].value, + }); + + const secretsList = useGetSecretsQuery({ + projectId: Number(currentProject), + offset: pagination.pageIndex * pagination.pageSize, + limit: pagination.pageSize, + }); + + const columns = useMemo[]>(() => secretsTableColumns(t), [i18n.language]); + + const data = useGetSecretsTableData(secretsList.data?.data); + + const tableConfig: MRT_TableOptions = { + columns, + data, + enablePagination: true, + enableRowSelection: true, + showGlobalFilter: true, + enableRowActions: true, + enableStickyHeader: true, + enableMultiSort: false, + enableSorting: false, + onPaginationChange: setPagination, + manualPagination: true, + rowCount: secretsList.data?.meta?.count ?? 0, + state: { + isLoading: secretsList.isFetching, + pagination, + }, + renderRowActionMenuItems: ({ closeMenu, row }) => , + }; + + return ( + <> + + + + ); +}; + +export default SecretsTable; diff --git a/console/ui/src/widgets/settings-form/index.ts b/console/ui/src/widgets/settings-form/index.ts new file mode 100644 index 000000000..cefc899ea --- /dev/null +++ b/console/ui/src/widgets/settings-form/index.ts @@ -0,0 +1,3 @@ +import SettingsForm from '@widgets/settings-form/ui'; + +export default SettingsForm; diff --git a/console/ui/src/widgets/settings-form/ui/index.tsx b/console/ui/src/widgets/settings-form/ui/index.tsx new file mode 100644 index 000000000..0d4148e7e --- /dev/null +++ b/console/ui/src/widgets/settings-form/ui/index.tsx @@ -0,0 +1,98 @@ +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 } 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 { + useGetSettingsQuery, + usePatchSettingsByNameMutation, + usePostSettingsMutation, +} from '@shared/api/api/settings.ts'; +import { LoadingButton } from '@mui/lab'; +import { toast } from 'react-toastify'; +import { handleRequestErrorCatch } from '@shared/lib/functions.ts'; +import Spinner from '@shared/ui/spinner'; + +const SettingsForm: FC = () => { + const { t } = useTranslation(['shared', 'toasts']); + + const [isResetting, setIsResetting] = useState(false); + + const methods = useForm({ + mode: 'all', + defaultValues: { + [SETTINGS_FORM_FIELDS_NAMES.HTTP_PROXY]: '', + [SETTINGS_FORM_FIELDS_NAMES.HTTPS_PROXY]: '', + }, + }); + + const settings = useGetSettingsQuery({ offset: 0, limit: 999_999_999 }); + const [postSettingsTrigger, postSettingsTriggerState] = usePostSettingsMutation(); + const [patchSettingsTrigger, patchSettingsTriggerState] = usePatchSettingsByNameMutation(); + + const { isValid, isDirty } = methods.formState; + + useEffect(() => { + if (settings.isFetching) setIsResetting(true); + if (settings.data?.data) { + // 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; + methods.reset((values) => ({ + ...values, + ...settingsData, + })); + }; + void resetForm().then(() => setIsResetting(false)); + } + }, [settings.data?.data, methods]); + + 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({ + name: 'proxy_env', + requestChangeSetting: { value: { ...filledFormValues } }, + }).unwrap() + : await postSettingsTrigger({ + requestCreateSetting: { + name: 'proxy_env', + value: { ...filledFormValues }, + }, + }).unwrap(); + toast.success(t('settingsSuccessfullyChanged', { ns: 'toasts' })); + methods.reset(values); + } catch (e) { + handleRequestErrorCatch(e); + } + }; + + return ( + + {isResetting || settings.isFetching ? ( + + ) : ( + +
+ + + + {t('save')} + + +
+
+ )} +
+ ); +}; + +export default SettingsForm; diff --git a/console/ui/src/widgets/sidebar/index.ts b/console/ui/src/widgets/sidebar/index.ts new file mode 100644 index 000000000..0ee5979a3 --- /dev/null +++ b/console/ui/src/widgets/sidebar/index.ts @@ -0,0 +1,3 @@ +import Sidebar from './ui'; + +export default Sidebar; diff --git a/console/ui/src/widgets/sidebar/model/constants.ts b/console/ui/src/widgets/sidebar/model/constants.ts new file mode 100644 index 000000000..79f8ab375 --- /dev/null +++ b/console/ui/src/widgets/sidebar/model/constants.ts @@ -0,0 +1,54 @@ +import { TFunction } from 'i18next'; +import RouterPaths from '@app/router/routerPathsConfig'; +import ClustersIcon from '@assets/clustersIcon.svg?react'; +import OperationsIcon from '@assets/operationsIcon.svg?react'; +import SettingsIcon from '@assets/settingsIcon.svg?react'; +import GithubIcon from '@assets/githubIcon.svg?react'; +import DocumentationIcon from '@assets/docsIcon.svg?react'; +import SupportIcon from '@assets/supportIcon.svg?react'; +import SponsorIcon from '@assets/sponsorIcon.svg?react'; + +export const sidebarData = (t: TFunction) => [ + { + icon: ClustersIcon, + label: t('clusters', { ns: 'clusters' }), + path: RouterPaths.clusters.absolutePath, + }, + { + icon: OperationsIcon, + label: t('operations', { ns: 'operations' }), + path: RouterPaths.operations.absolutePath, + }, + { + icon: SettingsIcon, + label: t('settings', { ns: 'settings' }), + path: RouterPaths.settings.absolutePath, + }, +]; + +export const sidebarLowData = (t: TFunction) => [ + { + icon: GithubIcon, + label: t('github', { ns: 'shared' }), + path: 'https://github.com/vitabaks/postgresql_cluster', + }, + { + icon: DocumentationIcon, + label: t('documentation', { ns: 'shared' }), + path: 'https://postgresql-cluster.org', + }, + { + icon: SupportIcon, + label: t('support', { ns: 'shared' }), + path: 'https://github.com/vitabaks/postgresql_cluster/issues', + }, + { + icon: SponsorIcon, + label: t('sponsor', { ns: 'shared' }), + path: 'https://github.com/vitabaks/postgresql_cluster?tab=readme-ov-file#sponsor-this-project', + }, +]; + +export const OPEN_SIDEBAR_WIDTH = '240px'; + +export const COLLAPSED_SIDEBAR_WIDTH = '61px'; diff --git a/console/ui/src/widgets/sidebar/ui/index.tsx b/console/ui/src/widgets/sidebar/ui/index.tsx new file mode 100644 index 000000000..79144bd95 --- /dev/null +++ b/console/ui/src/widgets/sidebar/ui/index.tsx @@ -0,0 +1,86 @@ +import { COLLAPSED_SIDEBAR_WIDTH, OPEN_SIDEBAR_WIDTH, sidebarData, sidebarLowData } from '../model/constants.ts'; +import SidebarItem from '@entities/sidebar-item'; +import { useTranslation } from 'react-i18next'; +import { useLocation } from 'react-router-dom'; +import { Box, Divider, Drawer, IconButton, List, Stack, Toolbar, useMediaQuery } from '@mui/material'; +import { useEffect, useState } from 'react'; +import CollapseIcon from '@shared/assets/collapseIcon.svg?react'; + +const Sidebar = () => { + const { t } = useTranslation('shared'); + const location = useLocation(); + + const [isCollapsed, setIsCollapsed] = useState(localStorage.getItem('isSidebarCollapsed')?.toString() === 'true'); + + const isLesserThan1600 = useMediaQuery('(max-width: 1600px)'); + + const toggleSidebarCollapse = () => { + setIsCollapsed((prev) => { + const newValue = !prev; + localStorage.setItem('isSidebarCollapsed', newValue); + return newValue; + }); + }; + + const isActive = (path: string) => { + return location.pathname?.includes(path); + }; + + useEffect(() => { + if ((!isCollapsed && isLesserThan1600) || (isCollapsed && !isLesserThan1600)) toggleSidebarCollapse(); + }, [isLesserThan1600]); + + const sidebarItems = sidebarData(t).map((item) => ( + + )); + + const sidebarLowIcons = sidebarLowData(t).map((item) => ( + + )); + + return ( + + + + {sidebarItems} + + + {sidebarLowIcons} + + + + + + + ); +}; + +export default Sidebar; diff --git a/console/ui/tsconfig.json b/console/ui/tsconfig.json new file mode 100644 index 000000000..0604aa23a --- /dev/null +++ b/console/ui/tsconfig.json @@ -0,0 +1,61 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "skipLibCheck": true, + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strictNullChecks": true, + "jsx": "react-jsx", + "baseUrl": "./", + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": [ + "./src/*" + ], + "@app/*": [ + "./src/app/*" + ], + "@assets/*": [ + "./src/shared/assets/*" + ], + "@entities/*": [ + "./src/entities/*" + ], + "@features/*": [ + "./src/features/*" + ], + "@pages/*": [ + "./src/pages/*" + ], + "@widgets/*": [ + "./src/widgets/*" + ], + "@shared/*": [ + "./src/shared/*" + ] + } + }, + "include": [ + "src" + ], + "references": [ + { + "path": "./tsconfig.node.json" + } + ] +} diff --git a/console/ui/tsconfig.node.json b/console/ui/tsconfig.node.json new file mode 100644 index 000000000..97ede7ee6 --- /dev/null +++ b/console/ui/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/console/ui/vite.config.mts b/console/ui/vite.config.mts new file mode 100644 index 000000000..c1c2bebd5 --- /dev/null +++ b/console/ui/vite.config.mts @@ -0,0 +1,28 @@ +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' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [svgr(), react()], + optimizeDeps: { + exclude: ['js-big-decimal'], + esbuildOptions: { + plugins: [fixReactVirtualized], + }, + }, + resolve: { + alias: { + '@': resolve(__dirname, './src'), + '@app': resolve(__dirname, './src/app'), + '@assets': resolve(__dirname, './src/shared/assets'), + '@entities': resolve(__dirname, './src/entities'), + '@features': resolve(__dirname, './src/features'), + '@pages': resolve(__dirname, './src/pages'), + '@widgets': resolve(__dirname, './src/widgets'), + '@shared': resolve(__dirname, './src/shared'), + }, + }, +}); diff --git a/console/ui/yarn.lock b/console/ui/yarn.lock new file mode 100644 index 000000000..98906fa1d --- /dev/null +++ b/console/ui/yarn.lock @@ -0,0 +1,5015 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@aashutoshrathi/word-wrap@^1.2.3": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz#bd9154aec9983f77b3a034ecaa015c2e4201f6cf" + integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA== + +"@adobe/css-tools@^4.3.2": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.3.3.tgz#90749bde8b89cd41764224f5aac29cd4138f75ff" + integrity sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ== + +"@ampproject/remapping@^2.2.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" + integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@apidevtools/json-schema-ref-parser@9.0.6": + version "9.0.6" + resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.6.tgz#5d9000a3ac1fd25404da886da6b266adcd99cf1c" + integrity sha512-M3YgsLjI0lZxvrpeGVk9Ap032W6TPQkH6pRAZz81Ac3WUNF79VQooAFnp8umjvVzUmD93NkogxEwbSce7qMsUg== + dependencies: + "@jsdevtools/ono" "^7.1.3" + call-me-maybe "^1.0.1" + js-yaml "^3.13.1" + +"@apidevtools/openapi-schemas@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz#9fa08017fb59d80538812f03fc7cac5992caaa17" + integrity sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ== + +"@apidevtools/swagger-methods@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz#b789a362e055b0340d04712eafe7027ddc1ac267" + integrity sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg== + +"@apidevtools/swagger-parser@^10.0.2", "@apidevtools/swagger-parser@^10.1.0": + version "10.1.0" + resolved "https://registry.yarnpkg.com/@apidevtools/swagger-parser/-/swagger-parser-10.1.0.tgz#a987d71e5be61feb623203be0c96e5985b192ab6" + integrity sha512-9Kt7EuS/7WbMAUv2gSziqjvxwDbFSg3Xeyfuj5laUODX8o/k/CpsAKiQ8W7/R88eXFTMbJYg6+7uAmOWNKmwnw== + dependencies: + "@apidevtools/json-schema-ref-parser" "9.0.6" + "@apidevtools/openapi-schemas" "^2.1.0" + "@apidevtools/swagger-methods" "^3.0.2" + "@jsdevtools/ono" "^7.1.3" + ajv "^8.6.3" + ajv-draft-04 "^1.0.0" + call-me-maybe "^1.0.1" + +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.23.5", "@babel/code-frame@^7.24.1", "@babel/code-frame@^7.24.2": + version "7.24.2" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.2.tgz#718b4b19841809a58b29b68cde80bc5e1aa6d9ae" + integrity sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ== + dependencies: + "@babel/highlight" "^7.24.2" + picocolors "^1.0.0" + +"@babel/compat-data@^7.23.5": + version "7.24.4" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.24.4.tgz#6f102372e9094f25d908ca0d34fc74c74606059a" + integrity sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ== + +"@babel/core@^7.21.3": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.24.5.tgz#15ab5b98e101972d171aeef92ac70d8d6718f06a" + integrity sha512-tVQRucExLQ02Boi4vdPp49svNGcfL2GhdTCT9aldhXgCJVAI21EtRfBettiuLUwce/7r6bFdgs6JFkcdTiFttA== + dependencies: + "@ampproject/remapping" "^2.2.0" + "@babel/code-frame" "^7.24.2" + "@babel/generator" "^7.24.5" + "@babel/helper-compilation-targets" "^7.23.6" + "@babel/helper-module-transforms" "^7.24.5" + "@babel/helpers" "^7.24.5" + "@babel/parser" "^7.24.5" + "@babel/template" "^7.24.0" + "@babel/traverse" "^7.24.5" + "@babel/types" "^7.24.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.23.5": + version "7.24.4" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.24.4.tgz#1f758428e88e0d8c563874741bc4ffc4f71a4717" + integrity sha512-MBVlMXP+kkl5394RBLSxxk/iLTeVGuXTV3cIDXavPpMMqnSnt6apKgan/U8O3USWZCWZT/TbgfEpKa4uMgN4Dg== + dependencies: + "@ampproject/remapping" "^2.2.0" + "@babel/code-frame" "^7.24.2" + "@babel/generator" "^7.24.4" + "@babel/helper-compilation-targets" "^7.23.6" + "@babel/helper-module-transforms" "^7.23.3" + "@babel/helpers" "^7.24.4" + "@babel/parser" "^7.24.4" + "@babel/template" "^7.24.0" + "@babel/traverse" "^7.24.1" + "@babel/types" "^7.24.0" + 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/generator@^7.24.1", "@babel/generator@^7.24.4": + version "7.24.4" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.4.tgz#1fc55532b88adf952025d5d2d1e71f946cb1c498" + integrity sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw== + dependencies: + "@babel/types" "^7.24.0" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^2.5.1" + +"@babel/generator@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.5.tgz#e5afc068f932f05616b66713e28d0f04e99daeb3" + integrity sha512-x32i4hEXvr+iI0NEoEfDKzlemF8AmtOP8CcrRaEcpzysWuoEb1KknpcvMsHKPONoKZiDuItklgWhB18xEhr9PA== + dependencies: + "@babel/types" "^7.24.5" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^2.5.1" + +"@babel/helper-compilation-targets@^7.23.6": + version "7.23.6" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz#4d79069b16cbcf1461289eccfbbd81501ae39991" + integrity sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ== + dependencies: + "@babel/compat-data" "^7.23.5" + "@babel/helper-validator-option" "^7.23.5" + browserslist "^4.22.2" + lru-cache "^5.1.1" + semver "^6.3.1" + +"@babel/helper-environment-visitor@^7.22.20": + version "7.22.20" + resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167" + integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA== + +"@babel/helper-function-name@^7.23.0": + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz#1f9a3cdbd5b2698a670c30d2735f9af95ed52759" + integrity sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw== + dependencies: + "@babel/template" "^7.22.15" + "@babel/types" "^7.23.0" + +"@babel/helper-hoist-variables@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz#c01a007dac05c085914e8fb652b339db50d823bb" + integrity sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw== + dependencies: + "@babel/types" "^7.22.5" + +"@babel/helper-module-imports@^7.16.7", "@babel/helper-module-imports@^7.22.15", "@babel/helper-module-imports@^7.24.3": + version "7.24.3" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz#6ac476e6d168c7c23ff3ba3cf4f7841d46ac8128" + integrity sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg== + dependencies: + "@babel/types" "^7.24.0" + +"@babel/helper-module-transforms@^7.23.3": + version "7.23.3" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz#d7d12c3c5d30af5b3c0fcab2a6d5217773e2d0f1" + integrity sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ== + dependencies: + "@babel/helper-environment-visitor" "^7.22.20" + "@babel/helper-module-imports" "^7.22.15" + "@babel/helper-simple-access" "^7.22.5" + "@babel/helper-split-export-declaration" "^7.22.6" + "@babel/helper-validator-identifier" "^7.22.20" + +"@babel/helper-module-transforms@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.24.5.tgz#ea6c5e33f7b262a0ae762fd5986355c45f54a545" + integrity sha512-9GxeY8c2d2mdQUP1Dye0ks3VDyIMS98kt/llQ2nUId8IsWqTF0l1LkSX0/uP7l7MCDrzXS009Hyhe2gzTiGW8A== + dependencies: + "@babel/helper-environment-visitor" "^7.22.20" + "@babel/helper-module-imports" "^7.24.3" + "@babel/helper-simple-access" "^7.24.5" + "@babel/helper-split-export-declaration" "^7.24.5" + "@babel/helper-validator-identifier" "^7.24.5" + +"@babel/helper-plugin-utils@^7.24.0": + version "7.24.0" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.0.tgz#945681931a52f15ce879fd5b86ce2dae6d3d7f2a" + integrity sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w== + +"@babel/helper-simple-access@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz#4938357dc7d782b80ed6dbb03a0fba3d22b1d5de" + integrity sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w== + dependencies: + "@babel/types" "^7.22.5" + +"@babel/helper-simple-access@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.24.5.tgz#50da5b72f58c16b07fbd992810be6049478e85ba" + integrity sha512-uH3Hmf5q5n7n8mz7arjUlDOCbttY/DW4DYhE6FUsjKJ/oYC1kQQUvwEQWxRwUpX9qQKRXeqLwWxrqilMrf32sQ== + dependencies: + "@babel/types" "^7.24.5" + +"@babel/helper-split-export-declaration@^7.22.6": + version "7.22.6" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz#322c61b7310c0997fe4c323955667f18fcefb91c" + integrity sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g== + dependencies: + "@babel/types" "^7.22.5" + +"@babel/helper-split-export-declaration@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.5.tgz#b9a67f06a46b0b339323617c8c6213b9055a78b6" + integrity sha512-5CHncttXohrHk8GWOFCcCl4oRD9fKosWlIRgWm4ql9VYioKm52Mk2xsmoohvm7f3JoiLSM5ZgJuRaf5QZZYd3Q== + dependencies: + "@babel/types" "^7.24.5" + +"@babel/helper-string-parser@^7.23.4", "@babel/helper-string-parser@^7.24.1": + version "7.24.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz#f99c36d3593db9540705d0739a1f10b5e20c696e" + integrity sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ== + +"@babel/helper-validator-identifier@^7.22.20": + version "7.22.20" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0" + integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A== + +"@babel/helper-validator-identifier@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz#918b1a7fa23056603506370089bd990d8720db62" + integrity sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA== + +"@babel/helper-validator-option@^7.23.5": + version "7.23.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz#907a3fbd4523426285365d1206c423c4c5520307" + integrity sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw== + +"@babel/helpers@^7.24.4": + version "7.24.4" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.24.4.tgz#dc00907fd0d95da74563c142ef4cd21f2cb856b6" + integrity sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw== + dependencies: + "@babel/template" "^7.24.0" + "@babel/traverse" "^7.24.1" + "@babel/types" "^7.24.0" + +"@babel/helpers@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.24.5.tgz#fedeb87eeafa62b621160402181ad8585a22a40a" + integrity sha512-CiQmBMMpMQHwM5m01YnrM6imUG1ebgYJ+fAIW4FZe6m4qHTPaRHti+R8cggAwkdz4oXhtO4/K9JWlh+8hIfR2Q== + dependencies: + "@babel/template" "^7.24.0" + "@babel/traverse" "^7.24.5" + "@babel/types" "^7.24.5" + +"@babel/highlight@^7.24.2": + version "7.24.2" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.24.2.tgz#3f539503efc83d3c59080a10e6634306e0370d26" + integrity sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA== + dependencies: + "@babel/helper-validator-identifier" "^7.22.20" + chalk "^2.4.2" + js-tokens "^4.0.0" + picocolors "^1.0.0" + +"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.24.0", "@babel/parser@^7.24.1", "@babel/parser@^7.24.4": + version "7.24.4" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.4.tgz#234487a110d89ad5a3ed4a8a566c36b9453e8c88" + integrity sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg== + +"@babel/parser@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.5.tgz#4a4d5ab4315579e5398a82dcf636ca80c3392790" + integrity sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg== + +"@babel/plugin-transform-react-jsx-self@^7.23.3": + version "7.24.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.24.1.tgz#a21d866d8167e752c6a7c4555dba8afcdfce6268" + integrity sha512-kDJgnPujTmAZ/9q2CN4m2/lRsUUPDvsG3+tSHWUJIzMGTt5U/b/fwWd3RO3n+5mjLrsBrVa5eKFRVSQbi3dF1w== + dependencies: + "@babel/helper-plugin-utils" "^7.24.0" + +"@babel/plugin-transform-react-jsx-source@^7.23.3": + version "7.24.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.24.1.tgz#a2dedb12b09532846721b5df99e52ef8dc3351d0" + integrity sha512-1v202n7aUq4uXAieRTKcwPzNyphlCuqHHDcdSNc+vdhoTEZcFMh+L5yZuCmGaIO7bs1nJUNfHB89TZyoL48xNA== + dependencies: + "@babel/helper-plugin-utils" "^7.24.0" + +"@babel/runtime@^7.12.5", "@babel/runtime@^7.18.3", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.9", "@babel/runtime@^7.24.0", "@babel/runtime@^7.24.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.5.tgz#230946857c053a36ccc66e1dd03b17dd0c4ed02c" + integrity sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g== + dependencies: + regenerator-runtime "^0.14.0" + +"@babel/runtime@^7.7.2": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.8.tgz#5d958c3827b13cc6d05e038c07fb2e5e3420d82e" + integrity sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA== + dependencies: + regenerator-runtime "^0.14.0" + +"@babel/template@^7.22.15", "@babel/template@^7.24.0": + version "7.24.0" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.0.tgz#c6a524aa93a4a05d66aaf31654258fae69d87d50" + integrity sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA== + dependencies: + "@babel/code-frame" "^7.23.5" + "@babel/parser" "^7.24.0" + "@babel/types" "^7.24.0" + +"@babel/traverse@^7.24.1": + version "7.24.1" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.1.tgz#d65c36ac9dd17282175d1e4a3c49d5b7988f530c" + integrity sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ== + dependencies: + "@babel/code-frame" "^7.24.1" + "@babel/generator" "^7.24.1" + "@babel/helper-environment-visitor" "^7.22.20" + "@babel/helper-function-name" "^7.23.0" + "@babel/helper-hoist-variables" "^7.22.5" + "@babel/helper-split-export-declaration" "^7.22.6" + "@babel/parser" "^7.24.1" + "@babel/types" "^7.24.0" + debug "^4.3.1" + globals "^11.1.0" + +"@babel/traverse@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.5.tgz#972aa0bc45f16983bf64aa1f877b2dd0eea7e6f8" + integrity sha512-7aaBLeDQ4zYcUFDUD41lJc1fG8+5IU9DaNSJAgal866FGvmD5EbWQgnEC6kO1gGLsX0esNkfnJSndbTXA3r7UA== + dependencies: + "@babel/code-frame" "^7.24.2" + "@babel/generator" "^7.24.5" + "@babel/helper-environment-visitor" "^7.22.20" + "@babel/helper-function-name" "^7.23.0" + "@babel/helper-hoist-variables" "^7.22.5" + "@babel/helper-split-export-declaration" "^7.24.5" + "@babel/parser" "^7.24.5" + "@babel/types" "^7.24.5" + debug "^4.3.1" + globals "^11.1.0" + +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.22.5", "@babel/types@^7.23.0", "@babel/types@^7.24.0": + version "7.24.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.0.tgz#3b951f435a92e7333eba05b7566fd297960ea1bf" + integrity sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w== + dependencies: + "@babel/helper-string-parser" "^7.23.4" + "@babel/helper-validator-identifier" "^7.22.20" + to-fast-properties "^2.0.0" + +"@babel/types@^7.21.3", "@babel/types@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.5.tgz#7661930afc638a5383eb0c4aee59b74f38db84d7" + integrity sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ== + dependencies: + "@babel/helper-string-parser" "^7.24.1" + "@babel/helper-validator-identifier" "^7.24.5" + to-fast-properties "^2.0.0" + +"@emotion/babel-plugin@^11.11.0": + version "11.11.0" + resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz#c2d872b6a7767a9d176d007f5b31f7d504bb5d6c" + integrity sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ== + dependencies: + "@babel/helper-module-imports" "^7.16.7" + "@babel/runtime" "^7.18.3" + "@emotion/hash" "^0.9.1" + "@emotion/memoize" "^0.8.1" + "@emotion/serialize" "^1.1.2" + babel-plugin-macros "^3.1.0" + convert-source-map "^1.5.0" + escape-string-regexp "^4.0.0" + find-root "^1.1.0" + source-map "^0.5.7" + stylis "4.2.0" + +"@emotion/cache@^11.11.0": + version "11.11.0" + resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-11.11.0.tgz#809b33ee6b1cb1a625fef7a45bc568ccd9b8f3ff" + integrity sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ== + dependencies: + "@emotion/memoize" "^0.8.1" + "@emotion/sheet" "^1.2.2" + "@emotion/utils" "^1.2.1" + "@emotion/weak-memoize" "^0.3.1" + stylis "4.2.0" + +"@emotion/hash@^0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.1.tgz#4ffb0055f7ef676ebc3a5a91fb621393294e2f43" + integrity sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ== + +"@emotion/is-prop-valid@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz#d4175076679c6a26faa92b03bb786f9e52612337" + integrity sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw== + dependencies: + "@emotion/memoize" "^0.8.1" + +"@emotion/memoize@^0.8.1": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.8.1.tgz#c1ddb040429c6d21d38cc945fe75c818cfb68e17" + integrity sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA== + +"@emotion/react@^11.11.4": + version "11.11.4" + resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.11.4.tgz#3a829cac25c1f00e126408fab7f891f00ecc3c1d" + integrity sha512-t8AjMlF0gHpvvxk5mAtCqR4vmxiGHCeJBaQO6gncUSdklELOgtwjerNY2yuJNfwnc6vi16U/+uMF+afIawJ9iw== + dependencies: + "@babel/runtime" "^7.18.3" + "@emotion/babel-plugin" "^11.11.0" + "@emotion/cache" "^11.11.0" + "@emotion/serialize" "^1.1.3" + "@emotion/use-insertion-effect-with-fallbacks" "^1.0.1" + "@emotion/utils" "^1.2.1" + "@emotion/weak-memoize" "^0.3.1" + hoist-non-react-statics "^3.3.1" + +"@emotion/serialize@^1.1.2", "@emotion/serialize@^1.1.3", "@emotion/serialize@^1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-1.1.4.tgz#fc8f6d80c492cfa08801d544a05331d1cc7cd451" + integrity sha512-RIN04MBT8g+FnDwgvIUi8czvr1LU1alUMI05LekWB5DGyTm8cCBMCRpq3GqaiyEDRptEXOyXnvZ58GZYu4kBxQ== + dependencies: + "@emotion/hash" "^0.9.1" + "@emotion/memoize" "^0.8.1" + "@emotion/unitless" "^0.8.1" + "@emotion/utils" "^1.2.1" + csstype "^3.0.2" + +"@emotion/sheet@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-1.2.2.tgz#d58e788ee27267a14342303e1abb3d508b6d0fec" + integrity sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA== + +"@emotion/styled@^11.11.5": + version "11.11.5" + resolved "https://registry.yarnpkg.com/@emotion/styled/-/styled-11.11.5.tgz#0c5c8febef9d86e8a926e663b2e5488705545dfb" + integrity sha512-/ZjjnaNKvuMPxcIiUkf/9SHoG4Q196DRl1w82hQ3WCsjo1IUR8uaGWrC6a87CrYAW0Kb/pK7hk8BnLgLRi9KoQ== + dependencies: + "@babel/runtime" "^7.18.3" + "@emotion/babel-plugin" "^11.11.0" + "@emotion/is-prop-valid" "^1.2.2" + "@emotion/serialize" "^1.1.4" + "@emotion/use-insertion-effect-with-fallbacks" "^1.0.1" + "@emotion/utils" "^1.2.1" + +"@emotion/unitless@^0.8.1": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.8.1.tgz#182b5a4704ef8ad91bde93f7a860a88fd92c79a3" + integrity sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ== + +"@emotion/use-insertion-effect-with-fallbacks@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz#08de79f54eb3406f9daaf77c76e35313da963963" + integrity sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw== + +"@emotion/utils@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-1.2.1.tgz#bbab58465738d31ae4cb3dbb6fc00a5991f755e4" + integrity sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg== + +"@emotion/weak-memoize@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz#d0fce5d07b0620caa282b5131c297bb60f9d87e6" + integrity sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww== + +"@esbuild/aix-ppc64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz#a70f4ac11c6a1dfc18b8bbb13284155d933b9537" + integrity sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g== + +"@esbuild/android-arm64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz#db1c9202a5bc92ea04c7b6840f1bbe09ebf9e6b9" + integrity sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg== + +"@esbuild/android-arm@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.20.2.tgz#3b488c49aee9d491c2c8f98a909b785870d6e995" + integrity sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w== + +"@esbuild/android-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.20.2.tgz#3b1628029e5576249d2b2d766696e50768449f98" + integrity sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg== + +"@esbuild/darwin-arm64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz#6e8517a045ddd86ae30c6608c8475ebc0c4000bb" + integrity sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA== + +"@esbuild/darwin-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz#90ed098e1f9dd8a9381695b207e1cff45540a0d0" + integrity sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA== + +"@esbuild/freebsd-arm64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz#d71502d1ee89a1130327e890364666c760a2a911" + integrity sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw== + +"@esbuild/freebsd-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz#aa5ea58d9c1dd9af688b8b6f63ef0d3d60cea53c" + integrity sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw== + +"@esbuild/linux-arm64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz#055b63725df678379b0f6db9d0fa85463755b2e5" + integrity sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A== + +"@esbuild/linux-arm@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz#76b3b98cb1f87936fbc37f073efabad49dcd889c" + integrity sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg== + +"@esbuild/linux-ia32@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz#c0e5e787c285264e5dfc7a79f04b8b4eefdad7fa" + integrity sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig== + +"@esbuild/linux-loong64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz#a6184e62bd7cdc63e0c0448b83801001653219c5" + integrity sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ== + +"@esbuild/linux-mips64el@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz#d08e39ce86f45ef8fc88549d29c62b8acf5649aa" + integrity sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA== + +"@esbuild/linux-ppc64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz#8d252f0b7756ffd6d1cbde5ea67ff8fd20437f20" + integrity sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg== + +"@esbuild/linux-riscv64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz#19f6dcdb14409dae607f66ca1181dd4e9db81300" + integrity sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg== + +"@esbuild/linux-s390x@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz#3c830c90f1a5d7dd1473d5595ea4ebb920988685" + integrity sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ== + +"@esbuild/linux-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz#86eca35203afc0d9de0694c64ec0ab0a378f6fff" + integrity sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw== + +"@esbuild/netbsd-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz#e771c8eb0e0f6e1877ffd4220036b98aed5915e6" + integrity sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ== + +"@esbuild/openbsd-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz#9a795ae4b4e37e674f0f4d716f3e226dd7c39baf" + integrity sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ== + +"@esbuild/sunos-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz#7df23b61a497b8ac189def6e25a95673caedb03f" + integrity sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w== + +"@esbuild/win32-arm64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz#f1ae5abf9ca052ae11c1bc806fb4c0f519bacf90" + integrity sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ== + +"@esbuild/win32-ia32@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz#241fe62c34d8e8461cd708277813e1d0ba55ce23" + integrity sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ== + +"@esbuild/win32-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz#9c907b21e30a52db959ba4f80bb01a0cc403d5cc" + integrity sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ== + +"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" + integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA== + dependencies: + eslint-visitor-keys "^3.3.0" + +"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.6.1": + version "4.10.0" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz#548f6de556857c8bb73bbee70c35dc82a2e74d63" + integrity sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA== + +"@eslint/eslintrc@^2.1.4": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad" + integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^9.6.0" + globals "^13.19.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@eslint/js@8.57.0": + version "8.57.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.0.tgz#a5417ae8427873f1dd08b70b3574b453e67b5f7f" + integrity sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g== + +"@exodus/schemasafe@^1.0.0-rc.2": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@exodus/schemasafe/-/schemasafe-1.3.0.tgz#731656abe21e8e769a7f70a4d833e6312fe59b7f" + integrity sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw== + +"@faker-js/faker@^8.4.1": + version "8.4.1" + resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-8.4.1.tgz#5d5e8aee8fce48f5e189bf730ebd1f758f491451" + integrity sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg== + +"@floating-ui/core@^1.0.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.0.tgz#fa41b87812a16bf123122bf945946bae3fdf7fc1" + integrity sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g== + dependencies: + "@floating-ui/utils" "^0.2.1" + +"@floating-ui/dom@^1.0.0": + version "1.6.5" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.5.tgz#323f065c003f1d3ecf0ff16d2c2c4d38979f4cb9" + integrity sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw== + dependencies: + "@floating-ui/core" "^1.0.0" + "@floating-ui/utils" "^0.2.0" + +"@floating-ui/react-dom@^2.0.8": + version "2.0.9" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.9.tgz#264ba8b061000baa132b5910f0427a6acf7ad7ce" + integrity sha512-q0umO0+LQK4+p6aGyvzASqKbKOJcAHJ7ycE9CuUvfx3s9zTHWmGJTPOIlM/hmSBfUfg/XfY5YhLBLR/LHwShQQ== + dependencies: + "@floating-ui/dom" "^1.0.0" + +"@floating-ui/utils@^0.2.0", "@floating-ui/utils@^0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2" + integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q== + +"@fontsource/roboto@^5.0.13": + version "5.0.13" + resolved "https://registry.yarnpkg.com/@fontsource/roboto/-/roboto-5.0.13.tgz#2d6ec431a2f9dfe38ca76525c2d6bf12241f575b" + integrity sha512-j61DHjsdUCKMXSdNLTOxcG701FWnF0jcqNNQi2iPCDxU8seN/sMxeh62dC++UiagCWq9ghTypX+Pcy7kX+QOeQ== + +"@hookform/resolvers@^3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-3.4.2.tgz#b69525248c2a9a1b2546411251ea25029915841a" + integrity sha512-1m9uAVIO8wVf7VCDAGsuGA0t6Z3m6jVGAN50HkV9vYLl0yixKK/Z1lr01vaRvYCkIKGoy1noVRxMzQYb4y/j1Q== + +"@humanwhocodes/config-array@^0.11.14": + version "0.11.14" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b" + integrity sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg== + dependencies: + "@humanwhocodes/object-schema" "^2.0.2" + debug "^4.3.1" + minimatch "^3.0.5" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/object-schema@^2.0.2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3" + integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== + +"@jest/schemas@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" + integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== + dependencies: + "@sinclair/typebox" "^0.27.8" + +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz#dcce6aff74bdf6dad1a95802b69b04a2fcb1fb36" + integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg== + dependencies: + "@jridgewell/set-array" "^1.2.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/set-array@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" + integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== + +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.4.15": + version "1.4.15" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" + integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== + +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": + version "0.3.25" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@jsdevtools/ono@^7.1.3": + version "7.1.3" + resolved "https://registry.yarnpkg.com/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" + integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== + +"@mattiasbuelens/web-streams-polyfill@^0.2.0": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@mattiasbuelens/web-streams-polyfill/-/web-streams-polyfill-0.2.1.tgz#d7c4aa94f98084ec0787be084d47167d62ea5f67" + integrity sha512-oKuFCQFa3W7Hj7zKn0+4ypI8JFm4ZKIoncwAC6wd5WwFW2sL7O1hpPoJdSWpynQ4DJ4lQ6MvFoVDmCLilonDFg== + dependencies: + "@types/whatwg-streams" "^0.0.7" + +"@monaco-editor/loader@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.4.0.tgz#f08227057331ec890fa1e903912a5b711a2ad558" + integrity sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg== + dependencies: + state-local "^1.0.6" + +"@monaco-editor/react@^4.6.0": + version "4.6.0" + resolved "https://registry.yarnpkg.com/@monaco-editor/react/-/react-4.6.0.tgz#bcc68671e358a21c3814566b865a54b191e24119" + integrity sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw== + dependencies: + "@monaco-editor/loader" "^1.4.0" + +"@mui/base@5.0.0-beta.40", "@mui/base@^5.0.0-beta.40": + version "5.0.0-beta.40" + resolved "https://registry.yarnpkg.com/@mui/base/-/base-5.0.0-beta.40.tgz#1f8a782f1fbf3f84a961e954c8176b187de3dae2" + integrity sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ== + dependencies: + "@babel/runtime" "^7.23.9" + "@floating-ui/react-dom" "^2.0.8" + "@mui/types" "^7.2.14" + "@mui/utils" "^5.15.14" + "@popperjs/core" "^2.11.8" + clsx "^2.1.0" + prop-types "^15.8.1" + +"@mui/core-downloads-tracker@^5.15.17": + version "5.15.17" + resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.17.tgz#ce8f3dff6ec11c8294d346997f6065eb23fa99be" + integrity sha512-DVAejDQkjNnIac7MfP8sLzuo7fyrBPxNdXe+6bYqOqg1z2OPTlfFAejSNzWe7UenRMuFu9/AyFXj/X2vN2w6dA== + +"@mui/icons-material@^5.15.17": + version "5.15.17" + resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-5.15.17.tgz#518c02354036f7df28c8f9890b1db6a3269fcc2f" + integrity sha512-xVzl2De7IY36s/keHX45YMiCpsIx3mNv2xwDgtBkRSnZQtVk+Gqufwj1ktUxEyjzEhBl0+PiNJqYC31C+n1n6A== + dependencies: + "@babel/runtime" "^7.23.9" + +"@mui/lab@^5.0.0-alpha.170": + version "5.0.0-alpha.170" + resolved "https://registry.yarnpkg.com/@mui/lab/-/lab-5.0.0-alpha.170.tgz#4519dfc8d1c51ca54fb9d8b91b95a3733d07be16" + integrity sha512-0bDVECGmrNjd3+bLdcLiwYZ0O4HP5j5WSQm5DV6iA/Z9kr8O6AnvZ1bv9ImQbbX7Gj3pX4o43EKwCutj3EQxQg== + dependencies: + "@babel/runtime" "^7.23.9" + "@mui/base" "5.0.0-beta.40" + "@mui/system" "^5.15.15" + "@mui/types" "^7.2.14" + "@mui/utils" "^5.15.14" + clsx "^2.1.0" + prop-types "^15.8.1" + +"@mui/material@^5.15.17": + version "5.15.17" + resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.15.17.tgz#1e30bacc940573813cc418aebd4484708a407ba6" + integrity sha512-ru/MLvTkCh0AZXmqwIpqGTOoVBS/sX48zArXq/DvktxXZx4fskiRA2PEc7Rk5ZlFiZhKh4moL4an+l8zZwq49Q== + dependencies: + "@babel/runtime" "^7.23.9" + "@mui/base" "5.0.0-beta.40" + "@mui/core-downloads-tracker" "^5.15.17" + "@mui/system" "^5.15.15" + "@mui/types" "^7.2.14" + "@mui/utils" "^5.15.14" + "@types/react-transition-group" "^4.4.10" + clsx "^2.1.0" + csstype "^3.1.3" + prop-types "^15.8.1" + react-is "^18.2.0" + react-transition-group "^4.4.5" + +"@mui/private-theming@^5.15.14": + version "5.15.14" + resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-5.15.14.tgz#edd9a82948ed01586a01c842eb89f0e3f68970ee" + integrity sha512-UH0EiZckOWcxiXLX3Jbb0K7rC8mxTr9L9l6QhOZxYc4r8FHUkefltV9VDGLrzCaWh30SQiJvAEd7djX3XXY6Xw== + dependencies: + "@babel/runtime" "^7.23.9" + "@mui/utils" "^5.15.14" + prop-types "^15.8.1" + +"@mui/styled-engine@^5.15.14": + version "5.15.14" + resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.15.14.tgz#168b154c4327fa4ccc1933a498331d53f61c0de2" + integrity sha512-RILkuVD8gY6PvjZjqnWhz8fu68dVkqhM5+jYWfB5yhlSQKg+2rHkmEwm75XIeAqI3qwOndK6zELK5H6Zxn4NHw== + dependencies: + "@babel/runtime" "^7.23.9" + "@emotion/cache" "^11.11.0" + csstype "^3.1.3" + prop-types "^15.8.1" + +"@mui/system@^5.15.14", "@mui/system@^5.15.15": + version "5.15.15" + resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.15.15.tgz#658771b200ce3c4a0f28e58169f02e5e718d1c53" + integrity sha512-aulox6N1dnu5PABsfxVGOZffDVmlxPOVgj56HrUnJE8MCSh8lOvvkd47cebIVQQYAjpwieXQXiDPj5pwM40jTQ== + dependencies: + "@babel/runtime" "^7.23.9" + "@mui/private-theming" "^5.15.14" + "@mui/styled-engine" "^5.15.14" + "@mui/types" "^7.2.14" + "@mui/utils" "^5.15.14" + clsx "^2.1.0" + csstype "^3.1.3" + prop-types "^15.8.1" + +"@mui/types@^7.2.14": + version "7.2.14" + resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.14.tgz#8a02ac129b70f3d82f2f9b76ded2c8d48e3fc8c9" + integrity sha512-MZsBZ4q4HfzBsywtXgM1Ksj6HDThtiwmOKUXH1pKYISI9gAVXCNHNpo7TlGoGrBaYWZTdNoirIN7JsQcQUjmQQ== + +"@mui/utils@^5.15.14": + version "5.15.14" + resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.15.14.tgz#e414d7efd5db00bfdc875273a40c0a89112ade3a" + integrity sha512-0lF/7Hh/ezDv5X7Pry6enMsbYyGKjADzvHyo3Qrc/SSlTsQ1VkbDMbH0m2t3OR5iIVLwMoxwM7yGd+6FCMtTFA== + dependencies: + "@babel/runtime" "^7.23.9" + "@types/prop-types" "^15.7.11" + prop-types "^15.8.1" + react-is "^18.2.0" + +"@mui/x-data-grid@^7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@mui/x-data-grid/-/x-data-grid-7.4.0.tgz#1901f2908aca760146ccae74b064fc15462bcf63" + integrity sha512-ILu0AVqqHQf4wN/nblsJ/k7PkvlB115vQ/FEiYk7neZlc/kOJOUyst3MWMVClAecZ8+JEs476q40xd4r1CtMfw== + dependencies: + "@babel/runtime" "^7.24.0" + "@mui/system" "^5.15.14" + "@mui/utils" "^5.15.14" + clsx "^2.1.1" + prop-types "^15.8.1" + reselect "^4.1.8" + +"@mui/x-date-pickers@^7.5.0": + version "7.5.0" + resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-7.5.0.tgz#3d1ce784079e874196007f853108183a5660a1b8" + integrity sha512-azm9AX36/XzllKtfyHn8u8iYDsxf425/LacP4oVaCeQQgIasajSRFxU/g8vxpNWwgTuzIeWwKjj8cvTc/2UBAw== + dependencies: + "@babel/runtime" "^7.24.5" + "@mui/base" "^5.0.0-beta.40" + "@mui/system" "^5.15.14" + "@mui/utils" "^5.15.14" + "@types/react-transition-group" "^4.4.10" + clsx "^2.1.1" + prop-types "^15.8.1" + react-transition-group "^4.4.5" + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@popperjs/core@^2.11.8": + version "2.11.8" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" + integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== + +"@reduxjs/toolkit@^2.2.6": + version "2.2.6" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.2.6.tgz#4a8356dad9d0c1ab255607a555d492168e0e3bc1" + integrity sha512-kH0r495c5z1t0g796eDQAkYbEQ3a1OLYN9o8jQQVZyKyw367pfRGS+qZLkHYvFHiUUdafpoSlQ2QYObIApjPWA== + dependencies: + immer "^10.0.3" + redux "^5.0.1" + redux-thunk "^3.1.0" + reselect "^5.1.0" + +"@remix-run/router@1.16.0": + version "1.16.0" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.16.0.tgz#0e10181e5fec1434eb071a9bc4bdaac843f16dcc" + integrity sha512-Quz1KOffeEf/zwkCBM3kBtH4ZoZ+pT3xIXBG4PPW/XFtDP7EGhtTiC2+gpL9GnR7+Qdet5Oa6cYSvwKYg6kN9Q== + +"@rollup/pluginutils@^5.0.5": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.1.0.tgz#7e53eddc8c7f483a4ad0b94afb1f7f5fd3c771e0" + integrity sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g== + dependencies: + "@types/estree" "^1.0.0" + estree-walker "^2.0.2" + picomatch "^2.3.1" + +"@rollup/rollup-android-arm-eabi@4.16.4": + version "4.16.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.16.4.tgz#5e8930291f1e5ead7fb1171d53ba5c87718de062" + integrity sha512-GkhjAaQ8oUTOKE4g4gsZ0u8K/IHU1+2WQSgS1TwTcYvL+sjbaQjNHFXbOJ6kgqGHIO1DfUhI/Sphi9GkRT9K+Q== + +"@rollup/rollup-android-arm64@4.16.4": + version "4.16.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.16.4.tgz#ffb84f1359c04ec8a022a97110e18a5600f5f638" + integrity sha512-Bvm6D+NPbGMQOcxvS1zUl8H7DWlywSXsphAeOnVeiZLQ+0J6Is8T7SrjGTH29KtYkiY9vld8ZnpV3G2EPbom+w== + +"@rollup/rollup-darwin-arm64@4.16.4": + version "4.16.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.16.4.tgz#b2fcee8d4806a0b1b9185ac038cc428ddedce9f4" + integrity sha512-i5d64MlnYBO9EkCOGe5vPR/EeDwjnKOGGdd7zKFhU5y8haKhQZTN2DgVtpODDMxUr4t2K90wTUJg7ilgND6bXw== + +"@rollup/rollup-darwin-x64@4.16.4": + version "4.16.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.16.4.tgz#fcb25ccbaa3dd33a6490e9d1c64bab2e0e16b932" + integrity sha512-WZupV1+CdUYehaZqjaFTClJI72fjJEgTXdf4NbW69I9XyvdmztUExBtcI2yIIU6hJtYvtwS6pkTkHJz+k08mAQ== + +"@rollup/rollup-linux-arm-gnueabihf@4.16.4": + version "4.16.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.16.4.tgz#40d46bdfe667e5eca31bf40047460e326d2e26bb" + integrity sha512-ADm/xt86JUnmAfA9mBqFcRp//RVRt1ohGOYF6yL+IFCYqOBNwy5lbEK05xTsEoJq+/tJzg8ICUtS82WinJRuIw== + +"@rollup/rollup-linux-arm-musleabihf@4.16.4": + version "4.16.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.16.4.tgz#7741df2448c11c56588b50835dbfe91b1a10b375" + integrity sha512-tJfJaXPiFAG+Jn3cutp7mCs1ePltuAgRqdDZrzb1aeE3TktWWJ+g7xK9SNlaSUFw6IU4QgOxAY4rA+wZUT5Wfg== + +"@rollup/rollup-linux-arm64-gnu@4.16.4": + version "4.16.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.16.4.tgz#0a23b02d2933e4c4872ad18d879890b6a4a295df" + integrity sha512-7dy1BzQkgYlUTapDTvK997cgi0Orh5Iu7JlZVBy1MBURk7/HSbHkzRnXZa19ozy+wwD8/SlpJnOOckuNZtJR9w== + +"@rollup/rollup-linux-arm64-musl@4.16.4": + version "4.16.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.16.4.tgz#e37ef259358aa886cc07d782220a4fb83c1e6970" + integrity sha512-zsFwdUw5XLD1gQe0aoU2HVceI6NEW7q7m05wA46eUAyrkeNYExObfRFQcvA6zw8lfRc5BHtan3tBpo+kqEOxmg== + +"@rollup/rollup-linux-powerpc64le-gnu@4.16.4": + version "4.16.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.16.4.tgz#8c69218b6de05ee2ba211664a2d2ac1e54e43f94" + integrity sha512-p8C3NnxXooRdNrdv6dBmRTddEapfESEUflpICDNKXpHvTjRRq1J82CbU5G3XfebIZyI3B0s074JHMWD36qOW6w== + +"@rollup/rollup-linux-riscv64-gnu@4.16.4": + version "4.16.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.16.4.tgz#d32727dab8f538d9a4a7c03bcf58c436aecd0139" + integrity sha512-Lh/8ckoar4s4Id2foY7jNgitTOUQczwMWNYi+Mjt0eQ9LKhr6sK477REqQkmy8YHY3Ca3A2JJVdXnfb3Rrwkng== + +"@rollup/rollup-linux-s390x-gnu@4.16.4": + version "4.16.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.16.4.tgz#d46097246a187d99fc9451fe8393b7155b47c5ec" + integrity sha512-1xwwn9ZCQYuqGmulGsTZoKrrn0z2fAur2ujE60QgyDpHmBbXbxLaQiEvzJWDrscRq43c8DnuHx3QorhMTZgisQ== + +"@rollup/rollup-linux-x64-gnu@4.16.4": + version "4.16.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.16.4.tgz#6356c5a03a4afb1c3057490fc51b4764e109dbc7" + integrity sha512-LuOGGKAJ7dfRtxVnO1i3qWc6N9sh0Em/8aZ3CezixSTM+E9Oq3OvTsvC4sm6wWjzpsIlOCnZjdluINKESflJLA== + +"@rollup/rollup-linux-x64-musl@4.16.4": + version "4.16.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.16.4.tgz#03a5831a9c0d05877b94653b5ddd3020d3c6fb06" + integrity sha512-ch86i7KkJKkLybDP2AtySFTRi5fM3KXp0PnHocHuJMdZwu7BuyIKi35BE9guMlmTpwwBTB3ljHj9IQXnTCD0vA== + +"@rollup/rollup-win32-arm64-msvc@4.16.4": + version "4.16.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.16.4.tgz#6cc0db57750376b9303bdb6f5482af8974fcae35" + integrity sha512-Ma4PwyLfOWZWayfEsNQzTDBVW8PZ6TUUN1uFTBQbF2Chv/+sjenE86lpiEwj2FiviSmSZ4Ap4MaAfl1ciF4aSA== + +"@rollup/rollup-win32-ia32-msvc@4.16.4": + version "4.16.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.16.4.tgz#aea0b7e492bd9ed46971cb80bc34f1eb14e07789" + integrity sha512-9m/ZDrQsdo/c06uOlP3W9G2ENRVzgzbSXmXHT4hwVaDQhYcRpi9bgBT0FTG9OhESxwK0WjQxYOSfv40cU+T69w== + +"@rollup/rollup-win32-x64-msvc@4.16.4": + version "4.16.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.16.4.tgz#c09ad9a132ccb5a67c4f211d909323ab1294f95f" + integrity sha512-YunpoOAyGLDseanENHmbFvQSfVL5BxW3k7hhy0eN4rb3gS/ct75dVD0EXOWIqFT/nE8XYW6LP6vz6ctKRi0k9A== + +"@rtk-query/codegen-openapi@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@rtk-query/codegen-openapi/-/codegen-openapi-1.2.0.tgz#2c63cbbd80382c4ba6c9fab5e9004efb43637de3" + integrity sha512-Sru3aPHyFC0Tb7jeFh/kVMGBdQUcofb9frrHhjNSRLEoJWsG9fjaioUx3nPT5HZVbdAvAFF4xMWFQNfgJBrAGw== + dependencies: + "@apidevtools/swagger-parser" "^10.0.2" + commander "^6.2.0" + oazapfts "^4.8.0" + prettier "^2.2.1" + semver "^7.3.5" + swagger2openapi "^7.0.4" + typescript "^5.0.0" + +"@sinclair/typebox@^0.27.8": + version "0.27.8" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" + integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== + +"@svgr/babel-plugin-add-jsx-attribute@8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz#4001f5d5dd87fa13303e36ee106e3ff3a7eb8b22" + integrity sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g== + +"@svgr/babel-plugin-remove-jsx-attribute@8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz#69177f7937233caca3a1afb051906698f2f59186" + integrity sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA== + +"@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz#c2c48104cfd7dcd557f373b70a56e9e3bdae1d44" + integrity sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA== + +"@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz#8fbb6b2e91fa26ac5d4aa25c6b6e4f20f9c0ae27" + integrity sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ== + +"@svgr/babel-plugin-svg-dynamic-title@8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz#1d5ba1d281363fc0f2f29a60d6d936f9bbc657b0" + integrity sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og== + +"@svgr/babel-plugin-svg-em-dimensions@8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz#35e08df300ea8b1d41cb8f62309c241b0369e501" + integrity sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g== + +"@svgr/babel-plugin-transform-react-native-svg@8.1.0": + version "8.1.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz#90a8b63998b688b284f255c6a5248abd5b28d754" + integrity sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q== + +"@svgr/babel-plugin-transform-svg-component@8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz#013b4bfca88779711f0ed2739f3f7efcefcf4f7e" + integrity sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw== + +"@svgr/babel-preset@8.1.0": + version "8.1.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-preset/-/babel-preset-8.1.0.tgz#0e87119aecdf1c424840b9d4565b7137cabf9ece" + integrity sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug== + dependencies: + "@svgr/babel-plugin-add-jsx-attribute" "8.0.0" + "@svgr/babel-plugin-remove-jsx-attribute" "8.0.0" + "@svgr/babel-plugin-remove-jsx-empty-expression" "8.0.0" + "@svgr/babel-plugin-replace-jsx-attribute-value" "8.0.0" + "@svgr/babel-plugin-svg-dynamic-title" "8.0.0" + "@svgr/babel-plugin-svg-em-dimensions" "8.0.0" + "@svgr/babel-plugin-transform-react-native-svg" "8.1.0" + "@svgr/babel-plugin-transform-svg-component" "8.0.0" + +"@svgr/core@^8.1.0": + version "8.1.0" + resolved "https://registry.yarnpkg.com/@svgr/core/-/core-8.1.0.tgz#41146f9b40b1a10beaf5cc4f361a16a3c1885e88" + integrity sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA== + dependencies: + "@babel/core" "^7.21.3" + "@svgr/babel-preset" "8.1.0" + camelcase "^6.2.0" + cosmiconfig "^8.1.3" + snake-case "^3.0.4" + +"@svgr/hast-util-to-babel-ast@8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz#6952fd9ce0f470e1aded293b792a2705faf4ffd4" + integrity sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q== + dependencies: + "@babel/types" "^7.21.3" + entities "^4.4.0" + +"@svgr/plugin-jsx@^8.1.0": + version "8.1.0" + resolved "https://registry.yarnpkg.com/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz#96969f04a24b58b174ee4cd974c60475acbd6928" + integrity sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA== + dependencies: + "@babel/core" "^7.21.3" + "@svgr/babel-preset" "8.1.0" + "@svgr/hast-util-to-babel-ast" "8.0.0" + svg-parser "^2.0.4" + +"@swc/core-darwin-arm64@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.5.0.tgz#fd56dedb26ebaaf028cc427d0cec998095a275ac" + integrity sha512-dyA25zQjm3xmMFsRPFgBpSqWSW9TITnkndZkZAiPYLjBxH9oTNMa0l09BePsaqEeXySY++tUgAeYu/9onsHLbg== + +"@swc/core-darwin-x64@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.5.0.tgz#cbbc00bba19c01ecd6f6c952b7c6b722f02ef515" + integrity sha512-cO7kZMMA/fcQIBT31LBzcVNSk3AZGVYLqvEPnJhFImjPm3mGKUd6kWpARUEGR68MyRU2VsWhE6eCjMcM+G7bxw== + +"@swc/core-linux-arm-gnueabihf@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.5.0.tgz#47316c552d7edd06fcd2585a28574f24a82cc4d3" + integrity sha512-BXaXytS4y9lBFRO6vwA6ovvy1d2ZIzS02i2R1oegoZzzNu89CJDpkYXYS9bId0GvK2m9Q9y2ofoZzKE2Rp3PqQ== + +"@swc/core-linux-arm64-gnu@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.5.0.tgz#c957fdc1bd24d49c2b063fb37147672c29fb4407" + integrity sha512-Bu4/41pGadXKnRsUbox0ig63xImATVH704oPCXcoOvNGkDyMjWgIAhzIi111vrwFNpj9utabgUE4AtlUa2tAOQ== + +"@swc/core-linux-arm64-musl@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.5.0.tgz#0416382c54182d2e3f326e422716ac3cf7dbad24" + integrity sha512-lUFFvC8tsepNcTnKEHNrePWanVVef6PQ82Rv9wIeebgGHRUqDh6+CyCqodXez+aKz6NyE/PBIfp0r+jPx4hoJA== + +"@swc/core-linux-x64-gnu@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.5.0.tgz#08ce35c57a0f58e0015731a2b38efce390b34903" + integrity sha512-c6LegFU1qdyMfk+GzNIOvrX61+mksm21Q01FBnXSy1nf1ACj/a86jmr3zkPl0zpNVHfPOw3Ry1QIuLQKD+67YA== + +"@swc/core-linux-x64-musl@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.5.0.tgz#73edc03943b2a7a06b14cfd4d195d6c0f953ef70" + integrity sha512-I/V8aWBmfDWwjtM1bS8ASG+6PcO/pVFYyPP5g2ok46Vz1o1MnAUd18mHnWX43nqVJokaW+BD/G4ZMZ+gXRl4zQ== + +"@swc/core-win32-arm64-msvc@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.5.0.tgz#cd07c068c1a06ad66beb69635481adde2845c396" + integrity sha512-nN685BvI7iM58xabrSOSQHUvIY10pcXh5H9DmS8LeYqG6Dkq7QZ8AwYqqonOitIS5C35MUfhSMLpOTzKoLdUqA== + +"@swc/core-win32-ia32-msvc@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.5.0.tgz#363fba59da64ccc3576f0525070e26966667b388" + integrity sha512-3YjltmEHljI+TvuDOC4lspUzjBUoB3X5BhftRBprSTJx/czuMl0vdoZKs2Snzb5Eqqesp0Rl8q+iQ1E1oJ6dEA== + +"@swc/core-win32-x64-msvc@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.5.0.tgz#6183c163076da0da6ce994898bcbd4630dbe7514" + integrity sha512-ZairtCwJsaxnUH85DcYCyGpNb9bUoIm9QXYW+VaEoXwbcB95dTIiJwN0aRxPT8B0B2MNw/CXLqjoPo6sDwz5iw== + +"@swc/core@^1.3.107": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.5.0.tgz#189a7770b0d95aeff8ca56b8763705cc27bae90f" + integrity sha512-fjADAC5gOOX54Rpcr1lF9DHLD+nPD5H/zXLtEgK2Ez3esmogT+LfHzCZtUxqetjvaMChKhQ0Pp0ZB6Hpz/tCbw== + dependencies: + "@swc/counter" "^0.1.2" + "@swc/types" "^0.1.5" + optionalDependencies: + "@swc/core-darwin-arm64" "1.5.0" + "@swc/core-darwin-x64" "1.5.0" + "@swc/core-linux-arm-gnueabihf" "1.5.0" + "@swc/core-linux-arm64-gnu" "1.5.0" + "@swc/core-linux-arm64-musl" "1.5.0" + "@swc/core-linux-x64-gnu" "1.5.0" + "@swc/core-linux-x64-musl" "1.5.0" + "@swc/core-win32-arm64-msvc" "1.5.0" + "@swc/core-win32-ia32-msvc" "1.5.0" + "@swc/core-win32-x64-msvc" "1.5.0" + +"@swc/counter@^0.1.2", "@swc/counter@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" + integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== + +"@swc/types@^0.1.5": + version "0.1.6" + resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.6.tgz#2f13f748995b247d146de2784d3eb7195410faba" + integrity sha512-/JLo/l2JsT/LRd80C3HfbmVpxOAJ11FO2RCEslFrgzLltoP9j8XIbsyDcfCt2WWyX+CM96rBoNM+IToAkFOugg== + dependencies: + "@swc/counter" "^0.1.3" + +"@tanstack/match-sorter-utils@8.15.1": + version "8.15.1" + resolved "https://registry.yarnpkg.com/@tanstack/match-sorter-utils/-/match-sorter-utils-8.15.1.tgz#715e028ff43cf79ece10bd5a757047a1016c3bba" + integrity sha512-PnVV3d2poenUM31ZbZi/yXkBu3J7kd5k2u51CGwwNojag451AjTH9N6n41yjXz2fpLeewleyLBmNS6+HcGDlXw== + dependencies: + remove-accents "0.5.0" + +"@tanstack/react-table@8.16.0": + version "8.16.0" + resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.16.0.tgz#92151210ff99d6925353d7a2205735d9c31af48c" + integrity sha512-rKRjnt8ostqN2fercRVOIH/dq7MAmOENCMvVlKx6P9Iokhh6woBGnIZEkqsY/vEJf1jN3TqLOb34xQGLVRuhAg== + dependencies: + "@tanstack/table-core" "8.16.0" + +"@tanstack/react-virtual@3.3.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.3.0.tgz#5a282efc1ed8da3d4e9e0f9b0c512f735d6c4b5f" + integrity sha512-QFxmTSZBniq15S0vSZ55P4ToXquMXwJypPXyX/ux7sYo6a2FX3/zWoRLLc4eIOGWTjvzqcIVNKhcuFb+OZL3aQ== + dependencies: + "@tanstack/virtual-core" "3.3.0" + +"@tanstack/table-core@8.16.0": + version "8.16.0" + resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.16.0.tgz#7b58018dd3cec8e0015fe22d6bb24d18d33c891f" + integrity sha512-dCG8vQGk4js5v88/k83tTedWOwjGnIyONrKpHpfmSJB8jwFHl8GSu1sBBxbtACVAPtAQgwNxl0rw1d3RqRM1Tg== + +"@tanstack/virtual-core@3.3.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.3.0.tgz#1bf72d51f269c5a0e3ac872c6b57116767f42c25" + integrity sha512-A0004OAa1FcUkPHeeGoKgBrAgjH+uHdDPrw1L7RpkwnODYqRvoilqsHPs8cyTjMg1byZBbiNpQAq2TlFLIaQag== + +"@testing-library/dom@^10.0.0", "@testing-library/dom@^10.1.0": + version "10.1.0" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.1.0.tgz#2d073e49771ad614da999ca48f199919e5176fb6" + integrity sha512-wdsYKy5zupPyLCW2Je5DLHSxSfbIp6h80WoHOQc+RPtmPGA52O9x5MJEkv92Sjonpq+poOAtUKhh1kBGAXBrNA== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^5.0.1" + aria-query "5.3.0" + chalk "^4.1.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.5.0" + pretty-format "^27.0.2" + +"@testing-library/jest-dom@^6.4.5": + version "6.4.5" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.4.5.tgz#badb40296477149136dabef32b572ddd3b56adf1" + integrity sha512-AguB9yvTXmCnySBP1lWjfNNUwpbElsaQ567lt2VdGqAdHtpieLgjmcVyv1q7PMIvLbgpDdkWV5Ydv3FEejyp2A== + dependencies: + "@adobe/css-tools" "^4.3.2" + "@babel/runtime" "^7.9.2" + aria-query "^5.0.0" + chalk "^3.0.0" + css.escape "^1.5.1" + dom-accessibility-api "^0.6.3" + lodash "^4.17.21" + redent "^3.0.0" + +"@testing-library/react@^15.0.6": + version "15.0.6" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-15.0.6.tgz#76be2e9e6da98c044823dfbc9d62ad3f10a3a401" + integrity sha512-UlbazRtEpQClFOiYp+1BapMT+xyqWMnE+hh9tn5DQ6gmlE7AIZWcGpzZukmDZuFk3By01oiqOf8lRedLS4k6xQ== + dependencies: + "@babel/runtime" "^7.12.5" + "@testing-library/dom" "^10.0.0" + "@types/react-dom" "^18.0.0" + +"@testing-library/user-event@^14.5.2": + version "14.5.2" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.5.2.tgz#db7257d727c891905947bd1c1a99da20e03c2ebd" + integrity sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ== + +"@types/aria-query@^5.0.1": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" + integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== + +"@types/babel__core@^7.20.5": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" + integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== + dependencies: + "@babel/parser" "^7.20.7" + "@babel/types" "^7.20.7" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.6.8" + resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.8.tgz#f836c61f48b1346e7d2b0d93c6dacc5b9535d3ab" + integrity sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw== + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f" + integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.20.5.tgz#7b7502be0aa80cc4ef22978846b983edaafcd4dd" + integrity sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ== + dependencies: + "@babel/types" "^7.20.7" + +"@types/estree@1.0.5", "@types/estree@^1.0.0": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" + integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== + +"@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@^20.12.10": + version "20.12.10" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.10.tgz#8f0c3f12b0f075eee1fe20c1afb417e9765bef76" + integrity sha512-Eem5pH9pmWBHoGAT8Dr5fdc5rYA+4NAovdM4EktRPVAAiJhmWWfQrA0cFhAbOsQdSfIHjAud6YdkbL69+zSKjw== + dependencies: + undici-types "~5.26.4" + +"@types/parse-json@^4.0.0": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239" + integrity sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw== + +"@types/prop-types@*", "@types/prop-types@^15.7.11": + version "15.7.12" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.12.tgz#12bb1e2be27293c1406acb6af1c3f3a1481d98c6" + integrity sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q== + +"@types/react-dom@^18.0.0": + version "18.3.0" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.0.tgz#0cbc818755d87066ab6ca74fbedb2547d74a82b0" + integrity sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg== + dependencies: + "@types/react" "*" + +"@types/react-dom@^18.2.22": + version "18.2.25" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.25.tgz#2946a30081f53e7c8d585eb138277245caedc521" + integrity sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA== + dependencies: + "@types/react" "*" + +"@types/react-lazylog@^4.5.4": + version "4.5.4" + resolved "https://registry.yarnpkg.com/@types/react-lazylog/-/react-lazylog-4.5.4.tgz#dc1a7ad962538ce564f7c5f5aaa01af464bf020d" + integrity sha512-HYP+lVRyE0c+fGT+IGHMqzQS5X9I7oaQ3iZczor2MQyLUXyAZRv2AJoEcYjH1QNPDIc+vMBSteyuSuw8tkGJ5Q== + dependencies: + "@types/react" "*" + immutable ">=3.8.2" + +"@types/react-transition-group@^4.4.10": + version "4.4.10" + resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.10.tgz#6ee71127bdab1f18f11ad8fb3322c6da27c327ac" + integrity sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q== + dependencies: + "@types/react" "*" + +"@types/react@*", "@types/react@^18.2.66": + version "18.2.79" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.79.tgz#c40efb4f255711f554d47b449f796d1c7756d865" + integrity sha512-RwGAGXPl9kSXwdNTafkOEuFrTBD5SA2B3iEB96xi8+xu5ddUa/cpvyVCSNn+asgLCTHkb5ZxN8gbuibYJi4s1w== + dependencies: + "@types/prop-types" "*" + csstype "^3.0.2" + +"@types/semver@^7.5.8": + version "7.5.8" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e" + integrity sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ== + +"@types/use-sync-external-store@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43" + integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA== + +"@types/whatwg-streams@^0.0.7": + version "0.0.7" + resolved "https://registry.yarnpkg.com/@types/whatwg-streams/-/whatwg-streams-0.0.7.tgz#28bfe73dc850562296367249c4b32a50db81e9d3" + integrity sha512-6sDiSEP6DWcY2ZolsJ2s39ZmsoGQ7KVwBDI3sESQsEm9P2dHTcqnDIHRZFRNtLCzWp7hCFGqYbw5GyfpQnJ01A== + +"@typescript-eslint/eslint-plugin@^7.2.0": + version "7.7.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.7.1.tgz#50a9044e3e5fe76b22caf64fb7fc1f97614bdbfd" + integrity sha512-KwfdWXJBOviaBVhxO3p5TJiLpNuh2iyXyjmWN0f1nU87pwyvfS0EmjC6ukQVYVFJd/K1+0NWGPDXiyEyQorn0Q== + dependencies: + "@eslint-community/regexpp" "^4.10.0" + "@typescript-eslint/scope-manager" "7.7.1" + "@typescript-eslint/type-utils" "7.7.1" + "@typescript-eslint/utils" "7.7.1" + "@typescript-eslint/visitor-keys" "7.7.1" + debug "^4.3.4" + graphemer "^1.4.0" + ignore "^5.3.1" + natural-compare "^1.4.0" + semver "^7.6.0" + ts-api-utils "^1.3.0" + +"@typescript-eslint/parser@^7.2.0": + version "7.7.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-7.7.1.tgz#f940e9f291cdca40c46cb75916217d3a42d6ceea" + integrity sha512-vmPzBOOtz48F6JAGVS/kZYk4EkXao6iGrD838sp1w3NQQC0W8ry/q641KU4PrG7AKNAf56NOcR8GOpH8l9FPCw== + dependencies: + "@typescript-eslint/scope-manager" "7.7.1" + "@typescript-eslint/types" "7.7.1" + "@typescript-eslint/typescript-estree" "7.7.1" + "@typescript-eslint/visitor-keys" "7.7.1" + debug "^4.3.4" + +"@typescript-eslint/scope-manager@7.7.1": + version "7.7.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.7.1.tgz#07fe59686ca843f66e3e2b5c151522bc38effab2" + integrity sha512-PytBif2SF+9SpEUKynYn5g1RHFddJUcyynGpztX3l/ik7KmZEv19WCMhUBkHXPU9es/VWGD3/zg3wg90+Dh2rA== + dependencies: + "@typescript-eslint/types" "7.7.1" + "@typescript-eslint/visitor-keys" "7.7.1" + +"@typescript-eslint/type-utils@7.7.1": + version "7.7.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.7.1.tgz#2f8094edca3bebdaad009008929df645ed9c8743" + integrity sha512-ZksJLW3WF7o75zaBPScdW1Gbkwhd/lyeXGf1kQCxJaOeITscoSl0MjynVvCzuV5boUz/3fOI06Lz8La55mu29Q== + dependencies: + "@typescript-eslint/typescript-estree" "7.7.1" + "@typescript-eslint/utils" "7.7.1" + debug "^4.3.4" + ts-api-utils "^1.3.0" + +"@typescript-eslint/types@7.7.1": + version "7.7.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.7.1.tgz#f903a651fb004c75add08e4e9e207f169d4b98d7" + integrity sha512-AmPmnGW1ZLTpWa+/2omPrPfR7BcbUU4oha5VIbSbS1a1Tv966bklvLNXxp3mrbc+P2j4MNOTfDffNsk4o0c6/w== + +"@typescript-eslint/typescript-estree@7.7.1": + version "7.7.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.7.1.tgz#5cafde48fe390fe1c1b329b2ce0ba8a73c1e87b2" + integrity sha512-CXe0JHCXru8Fa36dteXqmH2YxngKJjkQLjxzoj6LYwzZ7qZvgsLSc+eqItCrqIop8Vl2UKoAi0StVWu97FQZIQ== + dependencies: + "@typescript-eslint/types" "7.7.1" + "@typescript-eslint/visitor-keys" "7.7.1" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^1.3.0" + +"@typescript-eslint/utils@7.7.1": + version "7.7.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.7.1.tgz#5d161f2b4a55e1bc38b634bebb921e4bd4e4a16e" + integrity sha512-QUvBxPEaBXf41ZBbaidKICgVL8Hin0p6prQDu6bbetWo39BKbWJxRsErOzMNT1rXvTll+J7ChrbmMCXM9rsvOQ== + dependencies: + "@eslint-community/eslint-utils" "^4.4.0" + "@types/json-schema" "^7.0.15" + "@types/semver" "^7.5.8" + "@typescript-eslint/scope-manager" "7.7.1" + "@typescript-eslint/types" "7.7.1" + "@typescript-eslint/typescript-estree" "7.7.1" + semver "^7.6.0" + +"@typescript-eslint/visitor-keys@7.7.1": + version "7.7.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.7.1.tgz#da2294796220bb0f3b4add5ecbb1b9c3f4f65798" + integrity sha512-gBL3Eq25uADw1LQ9kVpf3hRM+DWzs0uZknHYK3hq4jcTPqVCClHGDnB6UUUV2SFeBeA4KWHWbbLqmbGcZ4FYbw== + dependencies: + "@typescript-eslint/types" "7.7.1" + eslint-visitor-keys "^3.4.3" + +"@ungap/structured-clone@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" + integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== + +"@vitejs/plugin-react-swc@^3.6.0": + version "3.6.0" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-react-swc/-/plugin-react-swc-3.6.0.tgz#dc9cd1363baf3780f3ad3e0a12a46a3ffe0c7526" + integrity sha512-XFRbsGgpGxGzEV5i5+vRiro1bwcIaZDIdBRP16qwm+jP68ue/S8FJTBEgOeojtVDYrbSua3XFp71kC8VJE6v+g== + dependencies: + "@swc/core" "^1.3.107" + +"@vitejs/plugin-react@^4.2.1": + version "4.2.1" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-4.2.1.tgz#744d8e4fcb120fc3dbaa471dadd3483f5a304bb9" + integrity sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ== + dependencies: + "@babel/core" "^7.23.5" + "@babel/plugin-transform-react-jsx-self" "^7.23.3" + "@babel/plugin-transform-react-jsx-source" "^7.23.3" + "@types/babel__core" "^7.20.5" + react-refresh "^0.14.0" + +"@vitest/expect@1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-1.6.0.tgz#0b3ba0914f738508464983f4d811bc122b51fb30" + integrity sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ== + dependencies: + "@vitest/spy" "1.6.0" + "@vitest/utils" "1.6.0" + chai "^4.3.10" + +"@vitest/runner@1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-1.6.0.tgz#a6de49a96cb33b0e3ba0d9064a3e8d6ce2f08825" + integrity sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg== + dependencies: + "@vitest/utils" "1.6.0" + p-limit "^5.0.0" + pathe "^1.1.1" + +"@vitest/snapshot@1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-1.6.0.tgz#deb7e4498a5299c1198136f56e6e0f692e6af470" + integrity sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ== + dependencies: + magic-string "^0.30.5" + pathe "^1.1.1" + pretty-format "^29.7.0" + +"@vitest/spy@1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-1.6.0.tgz#362cbd42ccdb03f1613798fde99799649516906d" + integrity sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw== + dependencies: + tinyspy "^2.2.0" + +"@vitest/utils@1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-1.6.0.tgz#5c5675ca7d6f546a7b4337de9ae882e6c57896a1" + integrity sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw== + dependencies: + diff-sequences "^29.6.3" + estree-walker "^3.0.3" + loupe "^2.3.7" + pretty-format "^29.7.0" + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn-walk@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.2.tgz#7703af9415f1b6db9315d6895503862e231d34aa" + integrity sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A== + +acorn@^8.11.3, acorn@^8.9.0: + version "8.11.3" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" + integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== + +agent-base@^7.0.2, agent-base@^7.1.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.1.tgz#bdbded7dfb096b751a2a087eeeb9664725b2e317" + integrity sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA== + dependencies: + debug "^4.3.4" + +ajv-draft-04@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz#3b64761b268ba0b9e668f0b41ba53fce0ad77fc8" + integrity sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw== + +ajv@^6.12.4: + version "6.12.6" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ajv@^8.6.3: + version "8.14.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.14.0.tgz#f514ddfd4756abb200e1704414963620a625ebbb" + integrity sha512-oYs1UUtO97ZO2lJ4bwnWeQW8/zvOIQLGKcvPTsWmvc2SYgBb+upuNS5NxoLaMU4h8Ju3Nbj6Cq8mD2LQoqVKFA== + dependencies: + fast-deep-equal "^3.1.3" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.4.1" + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +aria-query@5.3.0, aria-query@^5.0.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" + integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== + dependencies: + dequal "^2.0.3" + +array-buffer-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz#1e5583ec16763540a27ae52eed99ff899223568f" + integrity sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg== + dependencies: + call-bind "^1.0.5" + is-array-buffer "^3.0.4" + +array-includes@^3.1.6, array-includes@^3.1.7: + version "3.1.8" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.8.tgz#5e370cbe172fdd5dd6530c1d4aadda25281ba97d" + integrity sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-object-atoms "^1.0.0" + get-intrinsic "^1.2.4" + is-string "^1.0.7" + +array-union@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" + integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== + +array.prototype.findlast@^1.2.4: + version "1.2.5" + resolved "https://registry.yarnpkg.com/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz#3e4fbcb30a15a7f5bf64cf2faae22d139c2e4904" + integrity sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + es-shim-unscopables "^1.0.2" + +array.prototype.flat@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz#1476217df8cff17d72ee8f3ba06738db5b387d18" + integrity sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + es-shim-unscopables "^1.0.0" + +array.prototype.flatmap@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz#c9a7c6831db8e719d6ce639190146c24bbd3e527" + integrity sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + es-shim-unscopables "^1.0.0" + +array.prototype.toreversed@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/array.prototype.toreversed/-/array.prototype.toreversed-1.1.2.tgz#b989a6bf35c4c5051e1dc0325151bf8088954eba" + integrity sha512-wwDCoT4Ck4Cz7sLtgUmzR5UV3YF5mFHUlbChCzZBQZ+0m2cl/DH3tKgvphv1nKgFsJ48oCSg6p91q2Vm0I/ZMA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + es-shim-unscopables "^1.0.0" + +array.prototype.tosorted@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/array.prototype.tosorted/-/array.prototype.tosorted-1.1.3.tgz#c8c89348337e51b8a3c48a9227f9ce93ceedcba8" + integrity sha512-/DdH4TiTmOKzyQbp/eadcCVexiCb36xJg7HshYOYJnNZFDj33GEv0P7GxsynpShhq4OLYJzbGcBDkLsDt7MnNg== + dependencies: + call-bind "^1.0.5" + define-properties "^1.2.1" + es-abstract "^1.22.3" + es-errors "^1.1.0" + es-shim-unscopables "^1.0.2" + +arraybuffer.prototype.slice@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz#097972f4255e41bc3425e37dc3f6421cf9aefde6" + integrity sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A== + dependencies: + array-buffer-byte-length "^1.0.1" + call-bind "^1.0.5" + define-properties "^1.2.1" + es-abstract "^1.22.3" + es-errors "^1.2.1" + get-intrinsic "^1.2.3" + is-array-buffer "^3.0.4" + is-shared-array-buffer "^1.0.2" + +assertion-error@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" + integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +autoprefixer@^10.4.19: + version "10.4.19" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.19.tgz#ad25a856e82ee9d7898c59583c1afeb3fa65f89f" + integrity sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew== + dependencies: + browserslist "^4.23.0" + caniuse-lite "^1.0.30001599" + fraction.js "^4.3.7" + normalize-range "^0.1.2" + picocolors "^1.0.0" + postcss-value-parser "^4.2.0" + +available-typed-arrays@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" + integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== + dependencies: + possible-typed-array-names "^1.0.0" + +babel-plugin-macros@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz#9ef6dc74deb934b4db344dc973ee851d148c50c1" + integrity sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg== + dependencies: + "@babel/runtime" "^7.12.5" + cosmiconfig "^7.0.0" + resolve "^1.19.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +binary-extensions@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + +braces@^3.0.2, braces@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + dependencies: + fill-range "^7.0.1" + +browserslist@^4.22.2, browserslist@^4.23.0: + version "4.23.0" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.0.tgz#8f3acc2bbe73af7213399430890f86c63a5674ab" + integrity sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ== + dependencies: + caniuse-lite "^1.0.30001587" + electron-to-chromium "^1.4.668" + node-releases "^2.0.14" + update-browserslist-db "^1.0.13" + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +cac@^6.7.14: + version "6.7.14" + resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" + integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== + +call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + +call-me-maybe@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.2.tgz#03f964f19522ba643b1b0693acb9152fe2074baa" + integrity sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ== + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camelcase@^6.2.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +caniuse-lite@^1.0.30001587, caniuse-lite@^1.0.30001599: + version "1.0.30001612" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001612.tgz#d34248b4ec1f117b70b24ad9ee04c90e0b8a14ae" + integrity sha512-lFgnZ07UhaCcsSZgWW0K5j4e69dK1u/ltrL9lTUiFOwNHs12S3UMIEYgBV0Z6C6hRDev7iRnMzzYmKabYdXF9g== + +chai@^4.3.10: + version "4.4.1" + resolved "https://registry.yarnpkg.com/chai/-/chai-4.4.1.tgz#3603fa6eba35425b0f2ac91a009fe924106e50d1" + integrity sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g== + dependencies: + assertion-error "^1.1.0" + check-error "^1.0.3" + deep-eql "^4.1.3" + get-func-name "^2.0.2" + loupe "^2.3.6" + pathval "^1.1.1" + type-detect "^4.0.8" + +chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chalk@^4.0.0, chalk@^4.1.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +check-error@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.3.tgz#a6502e4312a7ee969f646e83bb3ddd56281bd694" + integrity sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg== + dependencies: + get-func-name "^2.0.2" + +"chokidar@>=3.0.0 <4.0.0": + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + +clsx@^1.0.4: + version "1.2.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" + integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== + +clsx@^2.1.0, clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +commander@^6.2.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" + integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +confbox@^0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.7.tgz#ccfc0a2bcae36a84838e83a3b7f770fb17d6c579" + integrity sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA== + +convert-source-map@^1.5.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" + integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +cosmiconfig@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6" + integrity sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA== + dependencies: + "@types/parse-json" "^4.0.0" + import-fresh "^3.2.1" + parse-json "^5.0.0" + path-type "^4.0.0" + yaml "^1.10.0" + +cosmiconfig@^8.1.3: + version "8.3.6" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-8.3.6.tgz#060a2b871d66dba6c8538ea1118ba1ac16f5fae3" + integrity sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA== + dependencies: + import-fresh "^3.3.0" + js-yaml "^4.1.0" + parse-json "^5.2.0" + path-type "^4.0.0" + +cross-fetch@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.0.0.tgz#f037aef1580bb3a1a35164ea2a848ba81b445983" + integrity sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g== + dependencies: + node-fetch "^2.6.12" + +cross-spawn@^7.0.2, cross-spawn@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +css.escape@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== + +cssstyle@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-4.0.1.tgz#ef29c598a1e90125c870525490ea4f354db0660a" + integrity sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ== + dependencies: + rrweb-cssom "^0.6.0" + +csstype@^3.0.2, csstype@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" + integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== + +data-urls@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-5.0.0.tgz#2f76906bce1824429ffecb6920f45a0b30f00dde" + integrity sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg== + dependencies: + whatwg-mimetype "^4.0.0" + whatwg-url "^14.0.0" + +data-view-buffer@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.1.tgz#8ea6326efec17a2e42620696e671d7d5a8bc66b2" + integrity sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +data-view-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz#90721ca95ff280677eb793749fce1011347669e2" + integrity sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +data-view-byte-offset@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz#5e0bbfb4828ed2d1b9b400cd8a7d119bca0ff18a" + integrity sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +date-fns@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.6.0.tgz#f20ca4fe94f8b754951b24240676e8618c0206bf" + integrity sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww== + +debug@4, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + +decimal.js@^10.4.3: + version "10.4.3" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" + integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== + +deep-eql@^4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.3.tgz#7c7775513092f7df98d8df9996dd085eb668cc6d" + integrity sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw== + dependencies: + type-detect "^4.0.0" + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +define-data-property@^1.0.1, define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +define-properties@^1.1.3, define-properties@^1.2.0, define-properties@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" + integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== + dependencies: + define-data-property "^1.0.1" + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +dequal@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + +diff-sequences@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" + integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== + +dir-glob@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" + integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== + dependencies: + path-type "^4.0.0" + +doctrine@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" + integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== + dependencies: + esutils "^2.0.2" + +doctrine@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + +dom-accessibility-api@^0.5.9: + version "0.5.16" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" + integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== + +dom-accessibility-api@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz#993e925cc1d73f2c662e7d75dd5a5445259a8fd8" + integrity sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w== + +dom-helpers@^5.0.1, dom-helpers@^5.1.3: + version "5.2.1" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" + integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA== + dependencies: + "@babel/runtime" "^7.8.7" + csstype "^3.0.2" + +dot-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751" + integrity sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w== + dependencies: + no-case "^3.0.4" + tslib "^2.0.3" + +electron-to-chromium@^1.4.668: + version "1.4.747" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.747.tgz#e37fa5b7b7e4c22607c5f59b5cf78f947266e77d" + integrity sha512-+FnSWZIAvFHbsNVmUxhEqWiaOiPMcfum1GQzlWCg/wLigVtshOsjXHyEFfmt6cFK6+HkS3QOJBv6/3OPumbBfw== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +entities@^4.4.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + +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== + dependencies: + is-arrayish "^0.2.1" + +es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.1, es-abstract@^1.23.2: + version "1.23.3" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.23.3.tgz#8f0c5a35cd215312573c5a27c87dfd6c881a0aa0" + integrity sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A== + dependencies: + array-buffer-byte-length "^1.0.1" + arraybuffer.prototype.slice "^1.0.3" + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + data-view-buffer "^1.0.1" + data-view-byte-length "^1.0.1" + data-view-byte-offset "^1.0.0" + es-define-property "^1.0.0" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + es-set-tostringtag "^2.0.3" + es-to-primitive "^1.2.1" + function.prototype.name "^1.1.6" + get-intrinsic "^1.2.4" + get-symbol-description "^1.0.2" + globalthis "^1.0.3" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + has-proto "^1.0.3" + has-symbols "^1.0.3" + hasown "^2.0.2" + internal-slot "^1.0.7" + is-array-buffer "^3.0.4" + is-callable "^1.2.7" + is-data-view "^1.0.1" + is-negative-zero "^2.0.3" + is-regex "^1.1.4" + is-shared-array-buffer "^1.0.3" + is-string "^1.0.7" + is-typed-array "^1.1.13" + is-weakref "^1.0.2" + object-inspect "^1.13.1" + object-keys "^1.1.1" + object.assign "^4.1.5" + regexp.prototype.flags "^1.5.2" + safe-array-concat "^1.1.2" + safe-regex-test "^1.0.3" + string.prototype.trim "^1.2.9" + string.prototype.trimend "^1.0.8" + string.prototype.trimstart "^1.0.8" + typed-array-buffer "^1.0.2" + typed-array-byte-length "^1.0.1" + typed-array-byte-offset "^1.0.2" + typed-array-length "^1.0.6" + unbox-primitive "^1.0.2" + which-typed-array "^1.1.15" + +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-errors@^1.1.0, es-errors@^1.2.1, es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-iterator-helpers@^1.0.17: + version "1.0.18" + resolved "https://registry.yarnpkg.com/es-iterator-helpers/-/es-iterator-helpers-1.0.18.tgz#4d3424f46b24df38d064af6fbbc89274e29ea69d" + integrity sha512-scxAJaewsahbqTYrGKJihhViaM6DDZDDoucfvzNbK0pOren1g/daDQ3IAhzn+1G14rBG7w+i5N+qul60++zlKA== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.0" + es-errors "^1.3.0" + es-set-tostringtag "^2.0.3" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + globalthis "^1.0.3" + has-property-descriptors "^1.0.2" + has-proto "^1.0.3" + has-symbols "^1.0.3" + internal-slot "^1.0.7" + iterator.prototype "^1.1.2" + safe-array-concat "^1.1.2" + +es-object-atoms@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz#ddb55cd47ac2e240701260bc2a8e31ecb643d941" + integrity sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw== + dependencies: + es-errors "^1.3.0" + +es-set-tostringtag@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz#8bb60f0a440c2e4281962428438d58545af39777" + integrity sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ== + dependencies: + get-intrinsic "^1.2.4" + has-tostringtag "^1.0.2" + hasown "^2.0.1" + +es-shim-unscopables@^1.0.0, es-shim-unscopables@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz#1f6942e71ecc7835ed1c8a83006d8771a63a3763" + integrity sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw== + dependencies: + hasown "^2.0.0" + +es-to-primitive@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" + integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +es6-promise@^3.2.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613" + integrity sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg== + +esbuild-plugin-react-virtualized@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/esbuild-plugin-react-virtualized/-/esbuild-plugin-react-virtualized-1.0.4.tgz#b8911ce8fae4636daa87cfa898752170f5d45609" + integrity sha512-/Y+82TBduHox0/uhJlTgUqi3ZWN+qZPF0xy9crkHQE2AOOdm76l6VY2F0Mdfvue9hqXz2FOlKHlHUVXNalHLzA== + +esbuild-runner@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/esbuild-runner/-/esbuild-runner-2.2.2.tgz#4243089f14c9690bff70beee16da3c41fd1dec50" + integrity sha512-fRFVXcmYVmSmtYm2mL8RlUASt2TDkGh3uRcvHFOKNr/T58VrfVeKD9uT9nlgxk96u0LS0ehS/GY7Da/bXWKkhw== + dependencies: + source-map-support "0.5.21" + tslib "2.4.0" + +esbuild@^0.20.1: + version "0.20.2" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.20.2.tgz#9d6b2386561766ee6b5a55196c6d766d28c87ea1" + integrity sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g== + optionalDependencies: + "@esbuild/aix-ppc64" "0.20.2" + "@esbuild/android-arm" "0.20.2" + "@esbuild/android-arm64" "0.20.2" + "@esbuild/android-x64" "0.20.2" + "@esbuild/darwin-arm64" "0.20.2" + "@esbuild/darwin-x64" "0.20.2" + "@esbuild/freebsd-arm64" "0.20.2" + "@esbuild/freebsd-x64" "0.20.2" + "@esbuild/linux-arm" "0.20.2" + "@esbuild/linux-arm64" "0.20.2" + "@esbuild/linux-ia32" "0.20.2" + "@esbuild/linux-loong64" "0.20.2" + "@esbuild/linux-mips64el" "0.20.2" + "@esbuild/linux-ppc64" "0.20.2" + "@esbuild/linux-riscv64" "0.20.2" + "@esbuild/linux-s390x" "0.20.2" + "@esbuild/linux-x64" "0.20.2" + "@esbuild/netbsd-x64" "0.20.2" + "@esbuild/openbsd-x64" "0.20.2" + "@esbuild/sunos-x64" "0.20.2" + "@esbuild/win32-arm64" "0.20.2" + "@esbuild/win32-ia32" "0.20.2" + "@esbuild/win32-x64" "0.20.2" + +escalade@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27" + integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA== + +escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-plugin-react-hooks@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz#4c3e697ad95b77e93f8646aaa1630c1ba607edd3" + integrity sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g== + +eslint-plugin-react-refresh@^0.4.6: + version "0.4.6" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.6.tgz#e8e8accab681861baed00c5c12da70267db0936f" + integrity sha512-NjGXdm7zgcKRkKMua34qVO9doI7VOxZ6ancSvBELJSSoX97jyndXcSoa8XBh69JoB31dNz3EEzlMcizZl7LaMA== + +eslint-plugin-react@^7.34.1: + version "7.34.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.34.1.tgz#6806b70c97796f5bbfb235a5d3379ece5f4da997" + integrity sha512-N97CxlouPT1AHt8Jn0mhhN2RrADlUAsk1/atcT2KyA/l9Q/E6ll7OIGwNumFmWfZ9skV3XXccYS19h80rHtgkw== + dependencies: + array-includes "^3.1.7" + array.prototype.findlast "^1.2.4" + array.prototype.flatmap "^1.3.2" + array.prototype.toreversed "^1.1.2" + array.prototype.tosorted "^1.1.3" + doctrine "^2.1.0" + es-iterator-helpers "^1.0.17" + estraverse "^5.3.0" + jsx-ast-utils "^2.4.1 || ^3.0.0" + minimatch "^3.1.2" + object.entries "^1.1.7" + object.fromentries "^2.0.7" + object.hasown "^1.1.3" + object.values "^1.1.7" + prop-types "^15.8.1" + resolve "^2.0.0-next.5" + semver "^6.3.1" + string.prototype.matchall "^4.0.10" + +eslint-scope@^7.2.2: + version "7.2.2" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.2.tgz#deb4f92563390f32006894af62a22dba1c46423f" + integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint@^8.57.0: + version "8.57.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.0.tgz#c786a6fd0e0b68941aaf624596fb987089195668" + integrity sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/regexpp" "^4.6.1" + "@eslint/eslintrc" "^2.1.4" + "@eslint/js" "8.57.0" + "@humanwhocodes/config-array" "^0.11.14" + "@humanwhocodes/module-importer" "^1.0.1" + "@nodelib/fs.walk" "^1.2.8" + "@ungap/structured-clone" "^1.2.0" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.3.2" + doctrine "^3.0.0" + escape-string-regexp "^4.0.0" + eslint-scope "^7.2.2" + eslint-visitor-keys "^3.4.3" + espree "^9.6.1" + esquery "^1.4.2" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + find-up "^5.0.0" + glob-parent "^6.0.2" + globals "^13.19.0" + graphemer "^1.4.0" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + is-path-inside "^3.0.3" + js-yaml "^4.1.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + strip-ansi "^6.0.1" + text-table "^0.2.0" + +espree@^9.6.0, espree@^9.6.1: + version "9.6.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" + integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== + dependencies: + acorn "^8.9.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.4.1" + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +esquery@^1.4.2: + version "1.5.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" + integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +estree-walker@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +execa@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-8.0.1.tgz#51f6a5943b580f963c3ca9c6321796db8cc39b8c" + integrity sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg== + dependencies: + cross-spawn "^7.0.3" + get-stream "^8.0.1" + human-signals "^5.0.0" + is-stream "^3.0.0" + merge-stream "^2.0.0" + npm-run-path "^5.1.0" + onetime "^6.0.0" + signal-exit "^4.1.0" + strip-final-newline "^3.0.0" + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-glob@^3.2.9: + version "3.3.2" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" + integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.4" + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fast-safe-stringify@^2.0.7: + version "2.1.1" + resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" + integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== + +fastq@^1.6.0: + version "1.17.1" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.17.1.tgz#2a523f07a4e7b1e81a42b91b8bf2254107753b47" + integrity sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w== + dependencies: + reusify "^1.0.4" + +fetch-readablestream@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/fetch-readablestream/-/fetch-readablestream-0.2.0.tgz#eaa6d1a76b12de2d4731a343393c6ccdcfe2c795" + integrity sha512-qu4mXWf4wus4idBIN/kVH+XSer8IZ9CwHP+Pd7DL7TuKNC1hP7ykon4kkBjwJF3EMX2WsFp4hH7gU7CyL7ucXw== + +file-entry-cache@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" + integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== + dependencies: + flat-cache "^3.0.4" + +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + dependencies: + to-regex-range "^5.0.1" + +find-root@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" + integrity sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng== + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat-cache@^3.0.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" + integrity sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.3" + rimraf "^3.0.2" + +flatted@^3.2.9: + version "3.3.1" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a" + integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw== + +for-each@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" + integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw== + dependencies: + is-callable "^1.1.3" + +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +fraction.js@^4.3.7: + version "4.3.7" + resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7" + integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +function.prototype.name@^1.1.5, function.prototype.name@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.6.tgz#cdf315b7d90ee77a4c6ee216c3c3362da07533fd" + integrity sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + functions-have-names "^1.2.3" + +functions-have-names@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" + integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== + +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" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-func-name@^2.0.1, get-func-name@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41" + integrity sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ== + +get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + +get-stream@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-8.0.1.tgz#def9dfd71742cd7754a7761ed43749a27d02eca2" + integrity sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA== + +get-symbol-description@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.2.tgz#533744d5aa20aca4e079c8e5daf7fd44202821f5" + integrity sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg== + dependencies: + call-bind "^1.0.5" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + +glob-parent@^5.1.2, glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob@^7.1.3: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +globals@^11.1.0: + version "11.12.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + +globals@^13.19.0: + version "13.24.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" + integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== + dependencies: + type-fest "^0.20.2" + +globalthis@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.3.tgz#5852882a52b80dc301b0660273e1ed082f0b6ccf" + integrity sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA== + dependencies: + define-properties "^1.1.3" + +globby@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" + integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.2.9" + ignore "^5.2.0" + merge2 "^1.4.1" + slash "^3.0.0" + +gopd@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" + integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + dependencies: + get-intrinsic "^1.1.3" + +graphemer@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== + +has-bigints@^1.0.1, has-bigints@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" + integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ== + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-proto@^1.0.1, has-proto@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" + integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== + +has-symbols@^1.0.2, has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + +has-tostringtag@^1.0.0, has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +highlight-words@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/highlight-words/-/highlight-words-1.2.2.tgz#9875b75d11814d7356b24f23feeb7d77761fa867" + integrity sha512-Mf4xfPXYm8Ay1wTibCrHpNWeR2nUMynMVFkXCi4mbl+TEgmNOe+I4hV7W3OCZcSvzGL6kupaqpfHOemliMTGxQ== + +hoist-non-react-statics@^3.3.1: + version "3.3.2" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" + integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== + dependencies: + react-is "^16.7.0" + +html-encoding-sniffer@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz#696df529a7cfd82446369dc5193e590a3735b448" + integrity sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ== + dependencies: + whatwg-encoding "^3.1.1" + +html-parse-stringify@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz#dfc1017347ce9f77c8141a507f233040c59c55d2" + integrity sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg== + dependencies: + void-elements "3.1.0" + +http-proxy-agent@^7.0.0: + version "7.0.2" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" + integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== + dependencies: + agent-base "^7.1.0" + debug "^4.3.4" + +http2-client@^1.2.5: + version "1.3.5" + resolved "https://registry.yarnpkg.com/http2-client/-/http2-client-1.3.5.tgz#20c9dc909e3cc98284dd20af2432c524086df181" + integrity sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA== + +https-proxy-agent@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz#8e97b841a029ad8ddc8731f26595bad868cb4168" + integrity sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg== + dependencies: + agent-base "^7.0.2" + debug "4" + +human-signals@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-5.0.0.tgz#42665a284f9ae0dade3ba41ebc37eb4b852f3a28" + integrity sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ== + +i18next-browser-languagedetector@^7.2.1: + version "7.2.1" + resolved "https://registry.yarnpkg.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-7.2.1.tgz#1968196d437b4c8db847410c7c33554f6c448f6f" + integrity sha512-h/pM34bcH6tbz8WgGXcmWauNpQupCGr25XPp9cZwZInR9XHSjIFDYp1SIok7zSPsTOMxdvuLyu86V+g2Kycnfw== + dependencies: + "@babel/runtime" "^7.23.2" + +i18next-fs-backend@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/i18next-fs-backend/-/i18next-fs-backend-2.3.1.tgz#0c7d2459ff4a039e2b3228131809fbc0e74ff1a8" + integrity sha512-tvfXskmG/9o+TJ5Fxu54sSO5OkY6d+uMn+K6JiUGLJrwxAVfer+8V3nU8jq3ts9Pe5lXJv4b1N7foIjJ8Iy2Gg== + +i18next-http-backend@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/i18next-http-backend/-/i18next-http-backend-2.5.1.tgz#97141b65d860a124b6c9feee181e565c753b0629" + integrity sha512-+rNX1tghdVxdfjfPt0bI1sNg5ahGW9kA7OboG7b4t03Fp69NdDlRIze6yXhIbN8rbHxJ8IP4dzRm/okZ15lkQg== + dependencies: + cross-fetch "4.0.0" + +i18next@^23.11.3: + version "23.11.3" + resolved "https://registry.yarnpkg.com/i18next/-/i18next-23.11.3.tgz#d269c9c15bae9d90ab291055cfc433089ca5f77b" + integrity sha512-Pq/aSKowir7JM0rj+Wa23Kb6KKDUGno/HjG+wRQu0PxoTbpQ4N89MAT0rFGvXmLkRLNMb1BbBOKGozl01dabzg== + dependencies: + "@babel/runtime" "^7.23.2" + +iconv-lite@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +ignore@^5.2.0, ignore@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef" + integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw== + +immer@^10.0.3: + version "10.1.1" + resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.1.tgz#206f344ea372d8ea176891545ee53ccc062db7bc" + integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw== + +immutable@>=3.8.2: + version "4.3.6" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.6.tgz#6a05f7858213238e587fb83586ffa3b4b27f0447" + integrity sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ== + +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@^4.0.0: + version "4.3.5" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.5.tgz#f8b436e66d59f99760dc577f5c99a4fd2a5cc5a0" + integrity sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw== + +import-fresh@^3.2.1, import-fresh@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" + integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +indent-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +internal-slot@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802" + integrity sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g== + dependencies: + es-errors "^1.3.0" + hasown "^2.0.0" + side-channel "^1.0.4" + +ip-regex@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-5.0.0.tgz#cd313b2ae9c80c07bd3851e12bf4fa4dc5480632" + integrity sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw== + +is-array-buffer@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.4.tgz#7a1f92b3d61edd2bc65d24f130530ea93d7fae98" + integrity sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.2.1" + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== + +is-async-function@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.0.0.tgz#8e4418efd3e5d3a6ebb0164c05ef5afb69aa9646" + integrity sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA== + dependencies: + has-tostringtag "^1.0.0" + +is-bigint@^1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" + integrity sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg== + dependencies: + has-bigints "^1.0.1" + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-boolean-object@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719" + integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: + version "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: + version "2.13.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384" + integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw== + dependencies: + hasown "^2.0.0" + +is-data-view@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.1.tgz#4b4d3a511b70f3dc26d42c03ca9ca515d847759f" + integrity sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w== + dependencies: + is-typed-array "^1.1.13" + +is-date-object@^1.0.1, is-date-object@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" + integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ== + dependencies: + has-tostringtag "^1.0.0" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-finalizationregistry@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz#c8749b65f17c133313e661b1289b95ad3dbd62e6" + integrity sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw== + dependencies: + call-bind "^1.0.2" + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-generator-function@^1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72" + integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A== + dependencies: + has-tostringtag "^1.0.0" + +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-map@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" + integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== + +is-negative-zero@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" + integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw== + +is-number-object@^1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.7.tgz#59d50ada4c45251784e9904f5246c742f07a42fc" + integrity sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ== + dependencies: + has-tostringtag "^1.0.0" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-path-inside@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== + +is-potential-custom-element-name@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" + integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== + +is-regex@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" + integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-set@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" + integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== + +is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz#1237f1cba059cdb62431d378dcc37d9680181688" + integrity sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg== + dependencies: + call-bind "^1.0.7" + +is-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-3.0.0.tgz#e6bfd7aa6bef69f4f472ce9bb681e3e57b4319ac" + integrity sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA== + +is-string@^1.0.5, is-string@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" + integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg== + dependencies: + has-tostringtag "^1.0.0" + +is-symbol@^1.0.2, is-symbol@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" + integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg== + dependencies: + has-symbols "^1.0.2" + +is-typed-array@^1.1.13: + version "1.1.13" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.13.tgz#d6c5ca56df62334959322d7d7dd1cca50debe229" + integrity sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw== + dependencies: + which-typed-array "^1.1.14" + +is-weakmap@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" + integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w== + +is-weakref@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" + integrity sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ== + dependencies: + call-bind "^1.0.2" + +is-weakset@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.3.tgz#e801519df8c0c43e12ff2834eead84ec9e624007" + integrity sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ== + dependencies: + call-bind "^1.0.7" + get-intrinsic "^1.2.4" + +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +iterator.prototype@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/iterator.prototype/-/iterator.prototype-1.1.2.tgz#5e29c8924f01916cb9335f1ff80619dcff22b0c0" + integrity sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w== + dependencies: + define-properties "^1.2.1" + get-intrinsic "^1.2.1" + has-symbols "^1.0.3" + reflect.getprototypeof "^1.0.4" + set-function-name "^2.0.1" + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-tokens@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.0.tgz#0f893996d6f3ed46df7f0a3b12a03f5fd84223c1" + integrity sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ== + +js-yaml@^3.13.1: + version "3.14.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" + integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +jsdom@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-24.0.0.tgz#e2dc04e4c79da368481659818ee2b0cd7c39007c" + integrity sha512-UDS2NayCvmXSXVP6mpTj+73JnNQadZlr9N68189xib2tx5Mls7swlTNao26IoHv46BZJFvXygyRtyXd1feAk1A== + dependencies: + cssstyle "^4.0.1" + data-urls "^5.0.0" + decimal.js "^10.4.3" + form-data "^4.0.0" + html-encoding-sniffer "^4.0.0" + http-proxy-agent "^7.0.0" + https-proxy-agent "^7.0.2" + is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.7" + parse5 "^7.1.2" + rrweb-cssom "^0.6.0" + saxes "^6.0.0" + symbol-tree "^3.2.4" + tough-cookie "^4.1.3" + w3c-xmlserializer "^5.0.0" + webidl-conversions "^7.0.0" + whatwg-encoding "^3.1.1" + whatwg-mimetype "^4.0.0" + whatwg-url "^14.0.0" + ws "^8.16.0" + xml-name-validator "^5.0.0" + +jsesc@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" + integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +json5@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +"jsx-ast-utils@^2.4.1 || ^3.0.0": + version "3.3.5" + resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz#4766bd05a8e2a11af222becd19e15575e52a853a" + integrity sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ== + dependencies: + array-includes "^3.1.6" + array.prototype.flat "^1.3.1" + object.assign "^4.1.4" + object.values "^1.1.6" + +keyv@^4.5.3: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +local-pkg@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.5.0.tgz#093d25a346bae59a99f80e75f6e9d36d7e8c925c" + integrity sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg== + dependencies: + mlly "^1.4.2" + pkg-types "^1.0.3" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash@^4.17.21, lodash@^4.17.4: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +loose-envify@^1.1.0, loose-envify@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +loupe@^2.3.6, loupe@^2.3.7: + version "2.3.7" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.7.tgz#6e69b7d4db7d3ab436328013d37d1c8c3540c697" + integrity sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA== + dependencies: + get-func-name "^2.0.1" + +lower-case@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" + integrity sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg== + dependencies: + tslib "^2.0.3" + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + +lz-string@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== + +magic-string@^0.30.5: + version "0.30.10" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.10.tgz#123d9c41a0cb5640c892b041d4cfb3bd0aa4b39e" + integrity sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.4.15" + +material-react-table@^2.13.0: + version "2.13.0" + resolved "https://registry.yarnpkg.com/material-react-table/-/material-react-table-2.13.0.tgz#445df17dd266a3177c2a1bb3114bd852f752e3da" + integrity sha512-ds4/cupDsXvoz8K8OpM3UqUyqKoAMkVdvmvP/+ovuWA23fPcjYvFFkUpBxtnZq5GKWM0+SZWzr14KQ1DgKCaFQ== + dependencies: + "@tanstack/match-sorter-utils" "8.15.1" + "@tanstack/react-table" "8.16.0" + "@tanstack/react-virtual" "3.3.0" + highlight-words "1.2.2" + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +merge2@^1.3.0, merge2@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +micromatch@^4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" + integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== + dependencies: + braces "^3.0.2" + picomatch "^2.3.1" + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mimic-fn@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc" + integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw== + +min-indent@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" + integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== + +minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^9.0.4: + version "9.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.4.tgz#8e49c731d1749cbec05050ee5145147b32496a51" + integrity sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw== + dependencies: + brace-expansion "^2.0.1" + +minimist@^1.2.8: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +mitt@^1.1.2: + version "1.2.0" + resolved "https://registry.yarnpkg.com/mitt/-/mitt-1.2.0.tgz#cb24e6569c806e31bd4e3995787fe38a04fdf90d" + integrity sha512-r6lj77KlwqLhIUku9UWYes7KJtsczvolZkzp8hbaDPPaE24OmWl5s539Mytlj22siEQKosZ26qCBgda2PKwoJw== + +mlly@^1.4.2, mlly@^1.6.1: + version "1.7.0" + resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.0.tgz#587383ae40dda23cadb11c3c3cc972b277724271" + integrity sha512-U9SDaXGEREBYQgfejV97coK0UL1r+qnF2SyO9A3qcI8MzKnsIFKHNVEkrDyNncQTKQQumsasmeq84eNMdBfsNQ== + dependencies: + acorn "^8.11.3" + pathe "^1.1.2" + pkg-types "^1.1.0" + ufo "^1.5.3" + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +nanoid@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +no-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" + integrity sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg== + dependencies: + lower-case "^2.0.2" + tslib "^2.0.3" + +node-fetch-h2@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/node-fetch-h2/-/node-fetch-h2-2.3.0.tgz#c6188325f9bd3d834020bf0f2d6dc17ced2241ac" + integrity sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg== + dependencies: + http2-client "^1.2.5" + +node-fetch@^2.6.1, node-fetch@^2.6.12: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + +node-readfiles@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/node-readfiles/-/node-readfiles-0.2.0.tgz#dbbd4af12134e2e635c245ef93ffcf6f60673a5d" + integrity sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA== + dependencies: + es6-promise "^3.2.1" + +node-releases@^2.0.14: + version "2.0.14" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" + integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw== + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +normalize-range@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" + integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA== + +normalize.css@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/normalize.css/-/normalize.css-8.0.1.tgz#9b98a208738b9cc2634caacbc42d131c97487bf3" + integrity sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg== + +npm-run-path@^5.1.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-5.3.0.tgz#e23353d0ebb9317f174e93417e4a4d82d0249e9f" + integrity sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ== + dependencies: + path-key "^4.0.0" + +nwsapi@^2.2.7: + version "2.2.9" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.9.tgz#7f3303218372db2e9f27c27766bcfc59ae7e61c6" + integrity sha512-2f3F0SEEer8bBu0dsNCFF50N0cTThV1nWFYcEYFZttdW0lDAoybv9cQoK7X7/68Z89S7FoRrVjP1LPX4XRf9vg== + +oas-kit-common@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/oas-kit-common/-/oas-kit-common-1.0.8.tgz#6d8cacf6e9097967a4c7ea8bcbcbd77018e1f535" + integrity sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ== + dependencies: + fast-safe-stringify "^2.0.7" + +oas-linter@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/oas-linter/-/oas-linter-3.2.2.tgz#ab6a33736313490659035ca6802dc4b35d48aa1e" + integrity sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ== + dependencies: + "@exodus/schemasafe" "^1.0.0-rc.2" + should "^13.2.1" + yaml "^1.10.0" + +oas-resolver@^2.5.6: + version "2.5.6" + resolved "https://registry.yarnpkg.com/oas-resolver/-/oas-resolver-2.5.6.tgz#10430569cb7daca56115c915e611ebc5515c561b" + integrity sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ== + dependencies: + node-fetch-h2 "^2.3.0" + oas-kit-common "^1.0.8" + reftools "^1.1.9" + yaml "^1.10.0" + yargs "^17.0.1" + +oas-schema-walker@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/oas-schema-walker/-/oas-schema-walker-1.1.5.tgz#74c3cd47b70ff8e0b19adada14455b5d3ac38a22" + integrity sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ== + +oas-validator@^5.0.8: + version "5.0.8" + resolved "https://registry.yarnpkg.com/oas-validator/-/oas-validator-5.0.8.tgz#387e90df7cafa2d3ffc83b5fb976052b87e73c28" + integrity sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw== + dependencies: + call-me-maybe "^1.0.1" + oas-kit-common "^1.0.8" + oas-linter "^3.2.2" + oas-resolver "^2.5.6" + oas-schema-walker "^1.1.5" + reftools "^1.1.9" + should "^13.2.1" + yaml "^1.10.0" + +oazapfts@^4.8.0: + version "4.12.0" + resolved "https://registry.yarnpkg.com/oazapfts/-/oazapfts-4.12.0.tgz#8a86c5fe5a1237b16b05d06d05815cffa2a2b949" + integrity sha512-hNKRG4eLYceuJuqDDx7Uqsi8p3j5k83gNKSo2qnUOTiiU03sCQOjXxOqCXDbzRcuDFyK94+1PBIpotK4NoxIjw== + dependencies: + "@apidevtools/swagger-parser" "^10.1.0" + lodash "^4.17.21" + minimist "^1.2.8" + swagger2openapi "^7.0.8" + typescript "^5.2.2" + +object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-inspect@^1.13.1: + version "1.13.1" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" + integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== + +object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object.assign@^4.1.4, object.assign@^4.1.5: + version "4.1.5" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.5.tgz#3a833f9ab7fdb80fc9e8d2300c803d216d8fdbb0" + integrity sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ== + dependencies: + call-bind "^1.0.5" + define-properties "^1.2.1" + has-symbols "^1.0.3" + object-keys "^1.1.1" + +object.entries@^1.1.7: + version "1.1.8" + resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.8.tgz#bffe6f282e01f4d17807204a24f8edd823599c41" + integrity sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +object.fromentries@^2.0.7: + version "2.0.8" + resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.8.tgz#f7195d8a9b97bd95cbc1999ea939ecd1a2b00c65" + integrity sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-object-atoms "^1.0.0" + +object.hasown@^1.1.3: + version "1.1.4" + resolved "https://registry.yarnpkg.com/object.hasown/-/object.hasown-1.1.4.tgz#e270ae377e4c120cdcb7656ce66884a6218283dc" + integrity sha512-FZ9LZt9/RHzGySlBARE3VF+gE26TxR38SdmqOqliuTnl9wrKulaQs+4dee1V+Io8VfxqzAfHu6YuRgUy8OHoTg== + dependencies: + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-object-atoms "^1.0.0" + +object.values@^1.1.6, object.values@^1.1.7: + version "1.2.0" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.0.tgz#65405a9d92cee68ac2d303002e0b8470a4d9ab1b" + integrity sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +onetime@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-6.0.0.tgz#7c24c18ed1fd2e9bca4bd26806a33613c77d34b4" + integrity sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ== + dependencies: + mimic-fn "^4.0.0" + +optionator@^0.9.3: + version "0.9.3" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.3.tgz#007397d44ed1872fdc6ed31360190f81814e2c64" + integrity sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg== + dependencies: + "@aashutoshrathi/word-wrap" "^1.2.3" + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-limit@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-5.0.0.tgz#6946d5b7140b649b7a33a027d89b4c625b3a5985" + integrity sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ== + dependencies: + yocto-queue "^1.0.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-json@^5.0.0, parse-json@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +parse5@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" + integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== + dependencies: + entities "^4.4.0" + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-key@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-4.0.0.tgz#295588dc3aee64154f877adb9d780b81c554bf18" + integrity sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + +pathe@^1.1.1, pathe@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec" + integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ== + +pathval@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" + integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== + +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== + +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +pkg-types@^1.0.3, pkg-types@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.1.0.tgz#3ec1bf33379030fd0a34c227b6c650e8ea7ca271" + integrity sha512-/RpmvKdxKf8uILTtoOhAgf30wYbP2Qw+L9p3Rvshx1JZVX+XQNZQFjlbmGHEGIm4CkVPlSn+NXmIM8+9oWQaSA== + dependencies: + confbox "^0.1.7" + mlly "^1.6.1" + pathe "^1.1.2" + +possible-typed-array-names@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f" + integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q== + +postcss-value-parser@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" + integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== + +postcss@^8.4.38: + version "8.4.38" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e" + integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A== + dependencies: + nanoid "^3.3.7" + picocolors "^1.0.0" + source-map-js "^1.2.0" + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +prettier@^2.2.1: + version "2.8.8" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" + integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== + +prettier@^3.2.5: + version "3.2.5" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368" + integrity sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A== + +pretty-format@^27.0.2: + version "27.5.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" + integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== + dependencies: + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + +pretty-format@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" + integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== + dependencies: + "@jest/schemas" "^29.6.3" + ansi-styles "^5.0.0" + react-is "^18.0.0" + +prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: + version "15.8.1" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" + integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.13.1" + +property-expr@^2.0.5: + version "2.0.6" + resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.6.tgz#f77bc00d5928a6c748414ad12882e83f24aec1e8" + integrity sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA== + +psl@^1.1.33: + version "1.9.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" + integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== + +punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +react-dom@^18.2.0: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" + integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.0" + +react-error-boundary@^4.0.13: + version "4.0.13" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-4.0.13.tgz#80386b7b27b1131c5fbb7368b8c0d983354c7947" + integrity sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ== + dependencies: + "@babel/runtime" "^7.12.5" + +react-hook-form@^7.51.5: + version "7.51.5" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.51.5.tgz#4afbfb819312db9fea23e8237a3a0d097e128b43" + integrity sha512-J2ILT5gWx1XUIJRETiA7M19iXHlG74+6O3KApzvqB/w8S5NQR7AbU8HVZrMALdmDgWpRPYiZJl0zx8Z4L2mP6Q== + +react-i18next@^14.1.1: + version "14.1.1" + resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-14.1.1.tgz#3d942a99866555ae3552c40f9fddfa061e29d7f3" + integrity sha512-QSiKw+ihzJ/CIeIYWrarCmXJUySHDwQr5y8uaNIkbxoGRm/5DukkxZs+RPla79IKyyDPzC/DRlgQCABHtrQuQQ== + dependencies: + "@babel/runtime" "^7.23.9" + html-parse-stringify "^3.0.1" + +react-is@^16.13.1, react-is@^16.7.0: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + +react-is@^17.0.1: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + +react-is@^18.0.0, react-is@^18.2.0: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" + integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== + +react-lazylog@^4.5.3: + version "4.5.3" + resolved "https://registry.yarnpkg.com/react-lazylog/-/react-lazylog-4.5.3.tgz#289e24995b5599e75943556ac63f5e2c04d0001e" + integrity sha512-lyov32A/4BqihgXgtNXTHCajXSXkYHPlIEmV8RbYjHIMxCFSnmtdg4kDCI3vATz7dURtiFTvrw5yonHnrS+NNg== + dependencies: + "@mattiasbuelens/web-streams-polyfill" "^0.2.0" + fetch-readablestream "^0.2.0" + immutable "^3.8.2" + mitt "^1.1.2" + prop-types "^15.6.1" + react-string-replace "^0.4.1" + react-virtualized "^9.21.0" + text-encoding-utf-8 "^1.0.1" + whatwg-fetch "^2.0.4" + +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== + +react-redux@^9.1.2: + version "9.1.2" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.1.2.tgz#deba38c64c3403e9abd0c3fbeab69ffd9d8a7e4b" + integrity sha512-0OA4dhM1W48l3uzmv6B7TXPCGmokUU4p1M44DGN2/D9a1FjVPukVjER1PcPX97jIg6aUeLq1XJo1IpfbgULn0w== + dependencies: + "@types/use-sync-external-store" "^0.0.3" + use-sync-external-store "^1.0.0" + +react-refresh@^0.14.0: + version "0.14.0" + resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e" + integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== + +react-router-dom@^6.23.0: + version "6.23.0" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.23.0.tgz#8b80ad92ad28f4dc38972e92d84b4c208150545a" + integrity sha512-Q9YaSYvubwgbal2c9DJKfx6hTNoBp3iJDsl+Duva/DwxoJH+OTXkxGpql4iUK2sla/8z4RpjAm6EWx1qUDuopQ== + dependencies: + "@remix-run/router" "1.16.0" + react-router "6.23.0" + +react-router@6.23.0: + version "6.23.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.23.0.tgz#2f2d7492c66a6bdf760be4c6bdf9e1d672fa154b" + integrity sha512-wPMZ8S2TuPadH0sF5irFGjkNLIcRvOSaEe7v+JER8508dyJumm6XZB1u5kztlX0RVq6AzRVndzqcUh6sFIauzA== + dependencies: + "@remix-run/router" "1.16.0" + +react-string-replace@^0.4.1: + version "0.4.4" + resolved "https://registry.yarnpkg.com/react-string-replace/-/react-string-replace-0.4.4.tgz#24006fbe0db573d5be583133df38b1a735cb4225" + integrity sha512-FAMkhxmDpCsGTwTZg7p/2v+/GTmxAp73so3fbSvlAcBBX36ujiGRNEaM/1u+jiYQrArhns+7eE92g2pi5E5FUA== + dependencies: + lodash "^4.17.4" + +react-toastify@^10.0.5: + version "10.0.5" + resolved "https://registry.yarnpkg.com/react-toastify/-/react-toastify-10.0.5.tgz#6b8f8386060c5c856239f3036d1e76874ce3bd1e" + integrity sha512-mNKt2jBXJg4O7pSdbNUfDdTsK9FIdikfsIE/yUCxbAEXl4HMyJaivrVFcn3Elvt5xvCQYhUZm+hqTIu1UXM3Pw== + dependencies: + clsx "^2.1.0" + +react-transition-group@^4.4.5: + version "4.4.5" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" + integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g== + dependencies: + "@babel/runtime" "^7.5.5" + dom-helpers "^5.0.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + +react-virtualized@^9.21.0: + version "9.22.5" + resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.22.5.tgz#bfb96fed519de378b50d8c0064b92994b3b91620" + integrity sha512-YqQMRzlVANBv1L/7r63OHa2b0ZsAaDp1UhVNEdUaXI8A5u6hTpA5NYtUueLH2rFuY/27mTGIBl7ZhqFKzw18YQ== + dependencies: + "@babel/runtime" "^7.7.2" + clsx "^1.0.4" + dom-helpers "^5.1.3" + loose-envify "^1.4.0" + prop-types "^15.7.2" + react-lifecycles-compat "^3.0.4" + +react@^18.2.0: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" + integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== + dependencies: + loose-envify "^1.1.0" + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +redent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" + integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== + dependencies: + indent-string "^4.0.0" + strip-indent "^3.0.0" + +redux-thunk@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3" + integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw== + +redux@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b" + integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== + +reflect.getprototypeof@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz#3ab04c32a8390b770712b7a8633972702d278859" + integrity sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.1" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + globalthis "^1.0.3" + which-builtin-type "^1.1.3" + +reftools@^1.1.9: + version "1.1.9" + resolved "https://registry.yarnpkg.com/reftools/-/reftools-1.1.9.tgz#e16e19f662ccd4648605312c06d34e5da3a2b77e" + integrity sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w== + +regenerator-runtime@^0.14.0: + version "0.14.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" + integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== + +regexp.prototype.flags@^1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz#138f644a3350f981a858c44f6bb1a61ff59be334" + integrity sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw== + dependencies: + call-bind "^1.0.6" + define-properties "^1.2.1" + es-errors "^1.3.0" + set-function-name "^2.0.1" + +remove-accents@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.5.0.tgz#77991f37ba212afba162e375b627631315bed687" + integrity sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A== + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + +reselect@^4.1.8: + version "4.1.8" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.1.8.tgz#3f5dc671ea168dccdeb3e141236f69f02eaec524" + integrity sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ== + +reselect@^5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.1.tgz#c766b1eb5d558291e5e550298adb0becc24bb72e" + integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w== + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve@^1.19.0: + version "1.22.8" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" + integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== + dependencies: + is-core-module "^2.13.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +resolve@^2.0.0-next.5: + version "2.0.0-next.5" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.5.tgz#6b0ec3107e671e52b68cd068ef327173b90dc03c" + integrity sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA== + dependencies: + is-core-module "^2.13.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +reusify@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" + integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== + +rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +rollup@^4.13.0: + version "4.16.4" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.16.4.tgz#fe328eb41293f20c9593a095ec23bdc4b5d93317" + integrity sha512-kuaTJSUbz+Wsb2ATGvEknkI12XV40vIiHmLuFlejoo7HtDok/O5eDDD0UpCVY5bBX5U5RYo8wWP83H7ZsqVEnA== + dependencies: + "@types/estree" "1.0.5" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.16.4" + "@rollup/rollup-android-arm64" "4.16.4" + "@rollup/rollup-darwin-arm64" "4.16.4" + "@rollup/rollup-darwin-x64" "4.16.4" + "@rollup/rollup-linux-arm-gnueabihf" "4.16.4" + "@rollup/rollup-linux-arm-musleabihf" "4.16.4" + "@rollup/rollup-linux-arm64-gnu" "4.16.4" + "@rollup/rollup-linux-arm64-musl" "4.16.4" + "@rollup/rollup-linux-powerpc64le-gnu" "4.16.4" + "@rollup/rollup-linux-riscv64-gnu" "4.16.4" + "@rollup/rollup-linux-s390x-gnu" "4.16.4" + "@rollup/rollup-linux-x64-gnu" "4.16.4" + "@rollup/rollup-linux-x64-musl" "4.16.4" + "@rollup/rollup-win32-arm64-msvc" "4.16.4" + "@rollup/rollup-win32-ia32-msvc" "4.16.4" + "@rollup/rollup-win32-x64-msvc" "4.16.4" + fsevents "~2.3.2" + +rrweb-cssom@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1" + integrity sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw== + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +safe-array-concat@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.2.tgz#81d77ee0c4e8b863635227c721278dd524c20edb" + integrity sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q== + dependencies: + call-bind "^1.0.7" + get-intrinsic "^1.2.4" + has-symbols "^1.0.3" + isarray "^2.0.5" + +safe-regex-test@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.3.tgz#a5b4c0f06e0ab50ea2c395c14d8371232924c377" + integrity sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-regex "^1.1.4" + +"safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +sass@^1.76.0: + version "1.76.0" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.76.0.tgz#fe15909500735ac154f0dc7386d656b62b03987d" + integrity sha512-nc3LeqvF2FNW5xGF1zxZifdW3ffIz5aBb7I7tSvOoNu7z1RQ6pFt9MBuiPtjgaI62YWrM/txjWlOCFiGtf2xpw== + dependencies: + chokidar ">=3.0.0 <4.0.0" + immutable "^4.0.0" + source-map-js ">=0.6.2 <2.0.0" + +saxes@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" + integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== + dependencies: + xmlchars "^2.2.0" + +scheduler@^0.23.0: + version "0.23.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" + integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw== + dependencies: + loose-envify "^1.1.0" + +semver@^6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.3.5: + version "7.6.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" + integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== + +semver@^7.6.0: + version "7.6.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" + integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== + dependencies: + lru-cache "^6.0.0" + +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +set-function-name@^2.0.1, set-function-name@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" + integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + functions-have-names "^1.2.3" + has-property-descriptors "^1.0.2" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +should-equal@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/should-equal/-/should-equal-2.0.0.tgz#6072cf83047360867e68e98b09d71143d04ee0c3" + integrity sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA== + dependencies: + should-type "^1.4.0" + +should-format@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/should-format/-/should-format-3.0.3.tgz#9bfc8f74fa39205c53d38c34d717303e277124f1" + integrity sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q== + dependencies: + should-type "^1.3.0" + should-type-adaptors "^1.0.1" + +should-type-adaptors@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz#401e7f33b5533033944d5cd8bf2b65027792e27a" + integrity sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA== + dependencies: + should-type "^1.3.0" + should-util "^1.0.0" + +should-type@^1.3.0, should-type@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/should-type/-/should-type-1.4.0.tgz#0756d8ce846dfd09843a6947719dfa0d4cff5cf3" + integrity sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ== + +should-util@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/should-util/-/should-util-1.0.1.tgz#fb0d71338f532a3a149213639e2d32cbea8bcb28" + integrity sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g== + +should@^13.2.1: + version "13.2.3" + resolved "https://registry.yarnpkg.com/should/-/should-13.2.3.tgz#96d8e5acf3e97b49d89b51feaa5ae8d07ef58f10" + integrity sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ== + dependencies: + should-equal "^2.0.0" + should-format "^3.0.3" + should-type "^1.4.0" + should-type-adaptors "^1.0.1" + should-util "^1.0.0" + +side-channel@^1.0.4, side-channel@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" + +siginfo@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + +signal-exit@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + +snake-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c" + integrity sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg== + dependencies: + dot-case "^3.0.4" + tslib "^2.0.3" + +"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" + integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg== + +source-map-support@0.5.21: + version "0.5.21" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map@^0.5.7: + version "0.5.7" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== + +source-map@^0.6.0: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + +stackback@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + +state-local@^1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/state-local/-/state-local-1.0.7.tgz#da50211d07f05748d53009bee46307a37db386d5" + integrity sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w== + +std-env@^3.5.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.7.0.tgz#c9f7386ced6ecf13360b6c6c55b8aaa4ef7481d2" + integrity sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg== + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string.prototype.matchall@^4.0.10: + version "4.0.11" + resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz#1092a72c59268d2abaad76582dccc687c0297e0a" + integrity sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-symbols "^1.0.3" + internal-slot "^1.0.7" + regexp.prototype.flags "^1.5.2" + set-function-name "^2.0.2" + side-channel "^1.0.6" + +string.prototype.trim@^1.2.9: + version "1.2.9" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz#b6fa326d72d2c78b6df02f7759c73f8f6274faa4" + integrity sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.0" + es-object-atoms "^1.0.0" + +string.prototype.trimend@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz#3651b8513719e8a9f48de7f2f77640b26652b229" + integrity sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +string.prototype.trimstart@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde" + integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-final-newline@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-3.0.0.tgz#52894c313fbff318835280aed60ff71ebf12b8fd" + integrity sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw== + +strip-indent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" + integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== + dependencies: + min-indent "^1.0.0" + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +strip-literal@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-2.1.0.tgz#6d82ade5e2e74f5c7e8739b6c84692bd65f0bd2a" + integrity sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw== + dependencies: + js-tokens "^9.0.0" + +stylis@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.2.0.tgz#79daee0208964c8fe695a42fcffcac633a211a51" + integrity sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw== + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +svg-parser@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5" + integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ== + +swagger2openapi@^7.0.4, swagger2openapi@^7.0.8: + version "7.0.8" + resolved "https://registry.yarnpkg.com/swagger2openapi/-/swagger2openapi-7.0.8.tgz#12c88d5de776cb1cbba758994930f40ad0afac59" + integrity sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g== + dependencies: + call-me-maybe "^1.0.1" + node-fetch "^2.6.1" + node-fetch-h2 "^2.3.0" + node-readfiles "^0.2.0" + oas-kit-common "^1.0.8" + oas-resolver "^2.5.6" + oas-schema-walker "^1.1.5" + oas-validator "^5.0.8" + reftools "^1.1.9" + yaml "^1.10.0" + yargs "^17.0.1" + +symbol-tree@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" + integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== + +text-encoding-utf-8@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz#585b62197b0ae437e3c7b5d0af27ac1021e10d13" + integrity sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg== + +text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== + +tiny-case@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-case/-/tiny-case-1.0.3.tgz#d980d66bc72b5d5a9ca86fb7c9ffdb9c898ddd03" + integrity sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q== + +tinybench@^2.5.1: + version "2.8.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.8.0.tgz#30e19ae3a27508ee18273ffed9ac7018949acd7b" + integrity sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw== + +tinypool@^0.8.3: + version "0.8.4" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.8.4.tgz#e217fe1270d941b39e98c625dcecebb1408c9aa8" + integrity sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ== + +tinyspy@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-2.2.1.tgz#117b2342f1f38a0dbdcc73a50a454883adf861d1" + integrity sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A== + +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +toposort@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" + integrity sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg== + +tough-cookie@^4.1.3: + version "4.1.4" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.4.tgz#945f1461b45b5a8c76821c33ea49c3ac192c1b36" + integrity sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.2.0" + url-parse "^1.5.3" + +tr46@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-5.0.0.tgz#3b46d583613ec7283020d79019f1335723801cec" + integrity sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g== + dependencies: + punycode "^2.3.1" + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +ts-api-utils@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1" + integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ== + +tslib@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" + integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== + +tslib@^2.0.3: + version "2.6.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +type-detect@^4.0.0, type-detect@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + +type-fest@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" + integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== + +type-fest@^2.19.0: + version "2.19.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" + integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== + +typed-array-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz#1867c5d83b20fcb5ccf32649e5e2fc7424474ff3" + integrity sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + is-typed-array "^1.1.13" + +typed-array-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz#d92972d3cff99a3fa2e765a28fcdc0f1d89dec67" + integrity sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + +typed-array-byte-offset@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz#f9ec1acb9259f395093e4567eb3c28a580d02063" + integrity sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + +typed-array-length@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.6.tgz#57155207c76e64a3457482dfdc1c9d1d3c4c73a3" + integrity sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + possible-typed-array-names "^1.0.0" + +typescript@^5.0.0, typescript@^5.2.2: + version "5.4.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611" + integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ== + +ufo@^1.5.3: + version "1.5.3" + resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.5.3.tgz#3325bd3c977b6c6cd3160bf4ff52989adc9d3344" + integrity sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw== + +unbox-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" + integrity sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw== + dependencies: + call-bind "^1.0.2" + has-bigints "^1.0.2" + has-symbols "^1.0.3" + which-boxed-primitive "^1.0.2" + +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + +universalify@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" + integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== + +update-browserslist-db@^1.0.13: + version "1.0.13" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz#3c5e4f5c083661bd38ef64b6328c26ed6c8248c4" + integrity sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg== + dependencies: + escalade "^3.1.1" + picocolors "^1.0.0" + +uri-js@^4.2.2, uri-js@^4.4.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +url-parse@^1.5.3: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + +use-sync-external-store@^1.0.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9" + integrity sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw== + +vite-node@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-1.6.0.tgz#2c7e61129bfecc759478fa592754fd9704aaba7f" + integrity sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw== + dependencies: + cac "^6.7.14" + debug "^4.3.4" + pathe "^1.1.1" + picocolors "^1.0.0" + vite "^5.0.0" + +vite-plugin-svgr@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/vite-plugin-svgr/-/vite-plugin-svgr-4.2.0.tgz#9f3bf5206b0ec510287e56d16f1915e729bb4e6b" + integrity sha512-SC7+FfVtNQk7So0XMjrrtLAbEC8qjFPifyD7+fs/E6aaNdVde6umlVVh0QuwDLdOMu7vp5RiGFsB70nj5yo0XA== + dependencies: + "@rollup/pluginutils" "^5.0.5" + "@svgr/core" "^8.1.0" + "@svgr/plugin-jsx" "^8.1.0" + +vite@^5.0.0: + version "5.2.11" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.2.11.tgz#726ec05555431735853417c3c0bfb36003ca0cbd" + integrity sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ== + dependencies: + esbuild "^0.20.1" + postcss "^8.4.38" + rollup "^4.13.0" + optionalDependencies: + fsevents "~2.3.3" + +vite@^5.2.0: + version "5.2.10" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.2.10.tgz#2ac927c91e99d51b376a5c73c0e4b059705f5bd7" + integrity sha512-PAzgUZbP7msvQvqdSD+ErD5qGnSFiGOoWmV5yAKUEI0kdhjbH6nMWVyZQC/hSc4aXwc0oJ9aEdIiF9Oje0JFCw== + dependencies: + esbuild "^0.20.1" + postcss "^8.4.38" + rollup "^4.13.0" + optionalDependencies: + fsevents "~2.3.3" + +vitest@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-1.6.0.tgz#9d5ad4752a3c451be919e412c597126cffb9892f" + integrity sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA== + dependencies: + "@vitest/expect" "1.6.0" + "@vitest/runner" "1.6.0" + "@vitest/snapshot" "1.6.0" + "@vitest/spy" "1.6.0" + "@vitest/utils" "1.6.0" + acorn-walk "^8.3.2" + chai "^4.3.10" + debug "^4.3.4" + execa "^8.0.1" + local-pkg "^0.5.0" + magic-string "^0.30.5" + pathe "^1.1.1" + picocolors "^1.0.0" + std-env "^3.5.0" + strip-literal "^2.0.0" + tinybench "^2.5.1" + tinypool "^0.8.3" + vite "^5.0.0" + vite-node "1.6.0" + why-is-node-running "^2.2.2" + +void-elements@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09" + integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w== + +w3c-xmlserializer@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz#f925ba26855158594d907313cedd1476c5967f6c" + integrity sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA== + dependencies: + xml-name-validator "^5.0.0" + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +webidl-conversions@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" + integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== + +whatwg-encoding@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz#d0f4ef769905d426e1688f3e34381a99b60b76e5" + integrity sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ== + dependencies: + iconv-lite "0.6.3" + +whatwg-fetch@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f" + integrity sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng== + +whatwg-mimetype@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz#bc1bf94a985dc50388d54a9258ac405c3ca2fc0a" + integrity sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg== + +whatwg-url@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-14.0.0.tgz#00baaa7fd198744910c4b1ef68378f2200e4ceb6" + integrity sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw== + dependencies: + tr46 "^5.0.0" + webidl-conversions "^7.0.0" + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +which-boxed-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" + integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== + dependencies: + is-bigint "^1.0.1" + is-boolean-object "^1.1.0" + is-number-object "^1.0.4" + is-string "^1.0.5" + is-symbol "^1.0.3" + +which-builtin-type@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.1.3.tgz#b1b8443707cc58b6e9bf98d32110ff0c2cbd029b" + integrity sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw== + dependencies: + function.prototype.name "^1.1.5" + has-tostringtag "^1.0.0" + is-async-function "^2.0.0" + is-date-object "^1.0.5" + is-finalizationregistry "^1.0.2" + is-generator-function "^1.0.10" + is-regex "^1.1.4" + is-weakref "^1.0.2" + isarray "^2.0.5" + which-boxed-primitive "^1.0.2" + which-collection "^1.0.1" + which-typed-array "^1.1.9" + +which-collection@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0" + integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== + dependencies: + is-map "^2.0.3" + is-set "^2.0.3" + is-weakmap "^2.0.2" + is-weakset "^2.0.3" + +which-typed-array@^1.1.14, which-typed-array@^1.1.15, which-typed-array@^1.1.9: + version "1.1.15" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.15.tgz#264859e9b11a649b388bfaaf4f767df1f779b38d" + integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-tostringtag "^1.0.2" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +why-is-node-running@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.2.2.tgz#4185b2b4699117819e7154594271e7e344c9973e" + integrity sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +ws@^8.16.0: + version "8.17.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.0.tgz#d145d18eca2ed25aaf791a183903f7be5e295fea" + integrity sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow== + +xml-name-validator@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673" + integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg== + +xmlchars@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" + integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + +yaml@^1.10.0: + version "1.10.2" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" + integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs@^17.0.1: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +yocto-queue@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" + integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== + +yup@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/yup/-/yup-1.4.0.tgz#898dcd660f9fb97c41f181839d3d65c3ee15a43e" + integrity sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg== + dependencies: + property-expr "^2.0.5" + tiny-case "^1.0.3" + toposort "^2.0.2" + type-fest "^2.19.0"